---
title: "Batch create accounts"
method: POST
path: "/v1/accounts/batch"
tags: ["Accounts"]
---

# Batch create accounts

`POST /v1/accounts/batch`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/accounts/batch`
- Auth: HTTP bearer

**Required permissions:** `accounts:write`.

Create up to 200 accounts in one call — the import path for bulk results (e.g. from `POST /v1/find`), where creating one account per HTTP call is the wrong shape. Every created account is assigned to the user your API key acts as, and starts becoming searchable, exactly like `POST /v1/accounts`. Read that route's description for what is and is not true of a new account; it applies to every row created here. Like that route, this call adds accounts only, never people.

**This call does not wait for the background work each new account needs.** That work is started without waiting for it, so 200 rows cost 200 inserts plus one website check that is capped at a few seconds; the accounts become searchable by `jobs` and `job_change` over the following minutes, and a discovery run tells you which are not ready yet rather than silently returning nothing for them.

**Each created row carries a prediction of which company it is.** Before answering, the websites of the created rows are checked together, and each row in `created` carries `identity`: `resolved` (with the company in `matchedCompany`), `uncertain` (several possible companies on that website, listed in `alternatives`), `not_found` (no company for that website: discovery will fail with `company_not_found`), or `pending` (no `url`, a `linkedinHandle` the website did not confirm, or the check did not answer in time; these are identified in the background, and a discovery run reports any that fail). Fix an `uncertain` or `not_found` account with `PATCH /v1/accounts/{id}` (its own website or its `linkedinHandle`), or delete it, before running discovery. `identity` is a prediction from the website: discovery runs its own match for each new account afterwards, and that match can land on a different company or on none. A discovery run lists any account it could not identify in `blockedSignals`; over MCP, `trayo_get_account_identity` reads the final result. Skipped rows carry none of this. These fields are on this response only, not on the account itself.

**Project a find result before posting it — it is not accepted verbatim.** A find company row carries `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `fundingStage`, `foundedYear`, `companyType`, `description`, `why`, `known`, `state` alongside `name` and `url`, this route accepts none of them, and an unprojected batch therefore lands EVERY row in `skipped` (the reason names the keys). The import that works:

```
curl -s .../v1/find -d '{"target":"companies","query":"..."}' | jq '{accounts: [.data[] | select(.state == "selectable" and .url) | {name, url}]}' \
  | curl -s .../v1/accounts/batch -H 'content-type: application/json' -d @-
```

The `select(.state == "selectable")` half matters as much as the projection: an `alreadyAdded` row would be skipped as a duplicate anyway.

**Partial success is the contract.** A malformed row, or one that collides with another row in the SAME batch or with an account you already have, is reported in `skipped` by its 0-based index and a reason — never as a request error. This response is always `200`, even if every row was skipped. Only a problem with the REQUEST itself fails the call, and an array over the 200-item cap is one of those: it ERRORS rather than clamping, unlike `POST /v1/find`'s `limit`.

Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **Dedup is best-effort, not a guarantee.** A row is a duplicate when its `url`'s hostname matches, lowercased and with a leading `www.` stripped (`https://acme.com` and `https://www.acme.com/careers` collide, and so does a stored value kept without a scheme, `acme.com`). Failing that, a row is a duplicate when its `linkedinHandle` matches — so a company you already have under a different domain (a rebrand, a regional TLD, an account whose `url` is empty) is still recognised. That comparison folds together every shape the handle is stored in: case, percent-encoding (`airbus%2Dhelicopters`), and a full company URL rather than the bare slug. Last, a row is a duplicate by `name`, case-insensitively, and how far that key reaches depends on what else the row carries. A row with NEITHER a `url` NOR a `linkedinHandle` has only its name, so it is matched against every account in your workspace, those with a website as well as those without — a name-only import is not created twice, and it attaches to the company you already have. A row that DOES carry a `url` or a handle and missed on it is matched by name only against an account that has NEITHER a usable website NOR a `linkedinHandle` — the only case where a name is the sole thing the two records share. Two accounts that each have a real website are never matched on name, so two different companies with the same name both land. The `reason` on the skip names WHICH of the three keys collided.

**A duplicate skip still attaches you to the account.** The account named by `existingId` is assigned to the user your key acts as, exactly as a created one is, so importing a company a teammate added already makes it eligible for your discovery runs. Importing a duplicate does not otherwise update the existing account; use `PATCH /v1/accounts/{id}` to correct its `name`, `url` or `linkedinHandle`.

This check happens in APPLICATION CODE — `Account.domain` has no unique database index, deliberately, because that migration needs a data backfill of its own. The consequence: two batches racing each other can both pass the duplicate check for the same key before either has committed, and both create an account. This endpoint cannot detect or prevent that race, there is no `Idempotency-Key` support here (unlike `POST /v1/discoveries`), and replaying the exact same request is not guaranteed to be a no-op. If your workflow cannot tolerate an occasional duplicate under concurrent imports, serialize your batch calls.

## Request body

- BatchCreateAccountsDto
  - `accounts` unknown[], required: Up to 200 rows, each shaped like a `POST /v1/accounts` body. More than 200 is a `validation_failed` ERROR (`param: "accounts"`), never a silent truncation — dropping the excess would lose records you believe you created. This is the same rule as `POST /v1/find`'s `limit`, which is rejected above 100. Individual rows are validated one at a time: a malformed row is reported in `skipped`, not raised as a request error.
    - unknown

## Response `200`

The request was processed. Check `skipped` — a `200` does not mean every row was created; it means the call itself succeeded.

- BatchCreateAccountsResultDto: **Partial success is the contract.** A batch of 40 with 3 bad rows creates 37 and reports the other 3 in `skipped` by their position in the submitted array. It never fails the whole request for a subset of bad rows, and the status is always `200` even when every row was skipped. Only a problem with the REQUEST itself — missing auth, the array missing or not an array, or more than 200 items — fails the call.
  - `created` object[], required: The rows that were created, in submission order.
    - `id` string, uuid, required
    - `name` string, required
    - `url` string, uri, nullable, required: The company's website as an absolute HTTP or HTTPS URL, or `null` when this workspace does not know one. `null` is expected rather than exceptional: an account created from a company NAME alone — by `POST /v1/people` with a `company` carrying no `domain`, or by a Trayo import — has no website until one is supplied. Set it with `PATCH /v1/accounts/{id}` (`trayo_update_account` over MCP). `logoUrl` is `null` for exactly these accounts, because it is derived from this field.
    - `logoUrl` string, nullable, required: The account's logo URL. Derived from `url`, so it is present for every account whose `url` is a usable domain and `null` otherwise. Cannot be set through this API. The URL returns image bytes (200) or redirects to them (302), so it is usable directly as an `<img src>`; it answers 404 when no logo resolves for the domain. Fetching it needs no API key.
    - `linkedinHandle` string, nullable, required
    - `starred` boolean, required
    - `scores` object, required: Trayo's own ranking of this account, published so you do not have to rebuild it by counting events. Both members are null on an account Trayo has not scored yet. This route does not sort by them — page through and sort client-side on `scores.timing.score` or `scores.activity.total`.
      - `timing` object, nullable, required: Null until Trayo has computed it. `calculatedAt` records when this score was last updated.
        - `score` integer, required: 0–100, NORMALIZED: this account's `raw` as a share of the highest `raw` in your workspace, rounded — the definition the product uses for its timing score. 100 is your hottest account right now, which means it moves when OTHER accounts move. Rank on this; threshold on `raw`.
        - `raw` number, required: The un-normalized score. How much your signals have fired at this account RECENTLY: each matched signal contributes its weight (1–3), decayed with a 30-day half-life, over a 90-day window, stacked and capped at 100. Stable for this account regardless of the rest of the workspace, so an absolute threshold on it holds still.
        - `calculatedAt` string, date-time, required
      - `activity` object, nullable, required: Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it.
        - `total` number, required: Weighted sum of event significance and relevance with recency decay and a diversity bonus, over `lookbackDays`. Scale depends on the workspace's scoring configuration — compare accounts within a workspace, not across workspaces.
        - `eventCount` integer, required: Events that contributed.
        - `avgSignificance` number, nullable, required
        - `avgRelevance` number, nullable, required
        - `recencyBonus` number, required
        - `signalDiversityBonus` number, required
        - `lookbackDays` integer, required
        - `summary` string, nullable, required: A generated prose summary of what the contributing events add up to for this account — the "why", not just the number. Null until generated; regenerated when new events arrive.
        - `summaryGeneratedAt` string, date-time, nullable, required
        - `calculatedAt` string, date-time, required
    - `pointOfView` object, nullable, required: Trayo's reading of what this account is doing and why it matters to what your workspace sells — the "why" behind the account, where `scores.activity.summary` is the "why" behind the number. Every prediction cites the events it rests on, so you can check the reading against the record instead of taking it on trust. Null until the first discovery run produces events and the point of view is generated; regenerated when new events arrive. Also null when the stored copy predates the current shape — an out-of-date reading is withheld rather than served in pieces. Reading this route never generates one.
      - `predictions` object[], required: At most three, strongest first. Empty when the events do not support a direction — an honest answer, not a failure.
        - `point` string, required: One sentence: where the company appears to be heading, and the need that follows from it. Deliberately hedged ("likely", "appears to be") — it is an outside-in reading of public events, not a confirmed internal plan.
        - `evidence` object[], required: The events the prediction rests on, at least one and at most three. A prediction with no evidence is never written, so this is never empty.
          - `eventId` string, uuid, required: A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards.
          - `eventTitle` string, required
          - `eventDate` string, date, required: The date the event happened, `YYYY-MM-DD` — not a timestamp.
          - `eventSummary` string, nullable, required
          - `connection` string, required: One line on how THIS event supports the prediction — the causal or strategic link, not a restatement of the title.
      - `whyNow` object[], required: At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for.
        - `signalKey` string, required: The signal whose recent firing makes the timing matter. Matches a `signalKey` on `GET /v1/signals` and on the cited events.
        - `point` string, required: One line on why the timing matters now.
        - `events` object[], required: The last few events that matched this signal, most recent first.
          - `eventId` string, uuid, required: A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards.
          - `eventTitle` string, required
          - `eventDate` string, date, required: The date the event happened, `YYYY-MM-DD` — not a timestamp.
      - `generatedAt` string, date-time, required: When this point of view was written.
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `identity` 'resolved' | 'uncertain' | 'not_found' | 'pending', required: A prediction of which company this account is, from its `url`, checked while the import ran. Discovery runs its own match for each new account afterwards, and that match can land on a different company or on none. Over MCP, `trayo_get_account_identity` reads that final result; over REST, a discovery run lists any account it could not identify in `blockedSignals`. `resolved`: the website belongs to the company in `matchedCompany`. `uncertain`: the website carries several possible companies and none clearly owns it; `alternatives` lists them. Discovery may pick the wrong one or none. `not_found`: Trayo holds no company for this website, so discovery will fail with `company_not_found` unless you correct the account. `pending`: not checked here: the row has no `url`, the row carries a `linkedinHandle` that the website check did not confirm (the handle is tried first later and may still succeed), or the check did not answer in time. Pending accounts are matched in the background. Fix an `uncertain` or `not_found` account with `PATCH /v1/accounts/{id}` (`trayo_update_account` over MCP): send the company's own website or its `linkedinHandle`. New values may be added; treat an unknown one as `pending`.
    - `matchedCompany` object, nullable, required: Only for `identity: "resolved"`: the company the website belongs to. Null otherwise.
      - `id` string, required: The company id. Send it in `filters.companyIds` on `POST /v1/find` to search this exact company.
      - `name` string, nullable, required: That company, as Trayo names it. Compare it with the name you sent.
      - `headcount` integer, nullable, required: Its headcount, when known.
      - `linkedinHandle` string, nullable, required: That company's LinkedIn handle, when Trayo has one. Send it as the account's `linkedinHandle` to pin the identity.
    - `alternatives` object[], nullable, required: Only for `identity: "uncertain"`: the companies this website could name, at most 3. Null otherwise.
      - `id` string, required: The company id. Send it in `filters.companyIds` (instead of `filters.companyWebsites`) to search this exact company.
      - `name` string, nullable, required: That company, as Trayo names it.
      - `headcount` integer, nullable, required: Its headcount, when known.
  - `skipped` object[], required: Rows that were NOT created. Always inspect this: a `200` means the call succeeded, not that every row landed.
    - `index` integer, required: 0-based position of the offending row in the submitted array.
    - `reason` string, required: Why this row was not created, in the same wording the single-create route would have returned for the same failure. PROSE — do not branch on it; branch on `reasonCode`.
    - `reasonCode` 'duplicate' | 'identity_conflict': The machine-readable class of the skip, present only for the classes a caller has to act on differently. `duplicate` means the row collided with a record you already have (or with an earlier row in the same batch) — `existingId` names it, and the remedy is a `PATCH`, not a re-post. `identity_conflict` means the submitted LinkedIn identity and email identify two different people; send one identity, or PATCH the intended record explicitly. Every other skip is a problem with the row itself: this field is ABSENT and `reason` says what to fix.
    - `existingId` string, uuid: The record this row collided with. Present exactly when `reasonCode` is `duplicate`, so a caller can `PATCH` the existing record instead of guessing which of its rows already existed. It may name a row created EARLIER IN THIS SAME batch.

## Other responses

- `400`: The request itself is malformed: `accounts` is missing, not an array, or has more than 200 items (`param: "accounts"`). A malformed INDIVIDUAL row never produces this status — it is reported in the `200` response's `skipped` instead. `validation_failed` (`retry: fix_input`) — The request did not pass input validation: a missing or malformed body field, a bad `limit`, a path parameter that is not a UUID, a `signalKey` that already exists or is reserved. `message` lists every failed constraint; `param` names the first offending field when one can be identified.
- `401`: Missing, malformed, or incorrect bearer token. Every way authentication can fail returns the identical body, so do not try to infer which one it was, and do not repeat the call with the same key. `unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.
- `402`: The workspace quota cannot cover the accounts this batch would create. Nothing was created. `quota_exceeded` (`retry: do_not_retry`) — This workspace has insufficient quota for this operation. Retrying or changing the request does not help; raise the quota or wait for it to renew.
- `403`: The key grant and its owner's current workspace role must both include every scope required by the operation. `insufficient_scope` (`retry: do_not_retry`) — Scope denial details distinguish missing_key_scope (use a key granting every missing scope), owner_permission_limit (the owner needs the missing workspace permissions), and invalid_key_scope (replace the key with supported scopes). Membership and undeclared-route denials remain generic.
- `429`: Every route is rate limited per workspace across REST, MCP, all keys and OAuth users, `GET /v1/whoami` included, so a balance poll competes with the work it is watching. For `rate_limited`, wait `Retry-After` seconds (also `details.retryAfterSeconds`), then send the same request once; do not retry sooner. `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` describe the workspace's bucket, and they come back on successful responses too: read them and slow down BEFORE you are refused. A burst from one network can also be refused before the key is checked; that 429 carries `Retry-After` and no `RateLimit-*` headers. `rate_limited` (`retry: retry_later`) — The workspace or source network exhausted its request budget for one bucket. `details.retryAfterSeconds` (and `Retry-After` on REST) is the wait: wait that long, then retry the same request once. Do not poll or retry sooner; an early retry is refused again. If you cannot wait, stop and tell the user when to retry; do not fill the wait with other calls. `details.limit` and `details.bucket` name the exhausted budget. When present, RateLimit-* headers describe the active budget and reset.
- `500`: Something failed on our side. Nothing about the request needs changing. `internal_error` (`retry: retry_later`) — Something failed on our side. `message` never describes it — quote `requestId` when reporting. Retry with exponential backoff and GIVE UP after a few attempts: `retry_later` here does not mean the failure is known to be transient, only that nothing about your request needs changing. A request that fails this way repeatedly will not start working.

---

[API](https://skmtc.dev/trayo/apis/trayo-api.md) · [All operations](https://skmtc.dev/trayo/apis/trayo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/trayo/apis/trayo-api/revisions/0af452dce6de?raw)
