---
title: "Batch create people"
method: POST
path: "/v1/people/batch"
tags: ["People"]
---

# Batch create people

`POST /v1/people/batch`

## Request

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

**Required permissions:** `people:write`.

Create up to 200 people in one call — the import path for bulk results (e.g. from `POST /v1/find`). A row is the same body as `POST /v1/people`, `note` included, so per-row sourcing ("found by search X, unverified") travels with the import instead of being flattened into `title`. Each row supports the same `accountId`/`company` auto-link as `POST /v1/people`, INCLUDING minting an account from a company name with no `domain`. That is the common case when piping the find contacts lane straight in here: those rows carry a company name and no domain, and each one now lands in an account rather than in `skipped`.

**Every new row is enriched from its LinkedIn identity**, exactly as on `POST /v1/people`: a new row that carries a `linkedinUrl` or `linkedinUsername` and leaves `title`, `photoUrl` or the account link empty has them filled from the profile Trayo has on file, and can land in an account it never named. Values you send are never overwritten, and a lookup that misses or runs long costs the row nothing. Strong-key upserts write only fields the row sent; they never copy these profile values onto the match.

**Partial success is the contract.** A malformed row, a row whose `accountId`/`company` cannot be resolved, or a name-only row that collides with another row in the SAME batch or with a person 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. An array over the 200-item cap ERRORS rather than clamping.

**Sending the same batch twice creates nothing the second time.** The same `linkedinUsername` or `email` (case-insensitive) updates the existing row with the fields this row sent. Those rows are returned in `updated`, with their submitted `index`; `created` contains new records only. Inside an account, a match on `fullName` alone is not safe enough to merge, so it stays in `skipped` as a duplicate and nothing is changed. The scope is the same rule as `POST /v1/people`: within the account the row NAMED (`accountId` or `company`), or across the whole workspace for a row that named neither. Name-only duplicates carry `reasonCode: "duplicate"` and `existingId`. Strong-key updates work for visible and hidden profiles alike and make the record rep-visible. If LinkedIn and email point at different existing people, the row is skipped with `reasonCode: "identity_conflict"` instead of fusing those identities. Send one identity, or PATCH the intended record explicitly. The same person under two DIFFERENT accounts you named is two records and both are created; a row with no account and neither a LinkedIn nor an email has nothing to match on and is always created.

**`fullName` is the third key, and it is BATCH-ONLY.** It applies only to a row that resolves to an account — an `accountId`, or a `company` that matched one you already have. Two people at ONE company sharing a full name is rare enough to be worth catching, whereas a bare name across a whole workspace collides too often to dedup on, so a row with no account is still matched on `linkedinUsername`/`email` alone and is created when it has neither. `fullName` is required, so a row that DOES name an account always carries this key: such a row can come back as a duplicate even when its `email` or `linkedinUsername` is one you have never seen. Read `existingId` before assuming the two rows are the same person. A single `POST /v1/people` never applies this key and still `201`s on a name that already exists.

**`linkedinUrl` carries identity here too.** A row with no `linkedinUsername` takes one from `linkedinUrl`, and that derived username is both what is stored and what the row is deduplicated on — so a `POST /v1/find` contacts import is idempotent on replay rather than landing a fresh copy of every contact.

**It is still best-effort, and here is the edge.** The check happens in APPLICATION CODE, the same posture as `POST /v1/accounts/batch` and for the same reason: no unique database index backs it. Two batches running CONCURRENTLY can both pass the check for the same person before either has committed, and both create one. Serialize your batch calls if you cannot tolerate that.

## Request body

- BatchCreatePeopleDto
  - `people` unknown[], required: Up to 200 rows, each shaped like a `POST /v1/people` body. More than 200 is a `validation_failed` ERROR (`param: "people"`), never a silent truncation — the deliberate same rule as `POST /v1/find`'s `limit`, which is rejected above 100. A row whose `accountId`/`company` cannot be resolved 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 or updated; it means the call itself succeeded. Read `created` for new records and `updated` for strong-key upserts. Then check `degraded`: those rows WERE created, but without an account (`no_account`) and/or without a LinkedIn/email identity (`no_identity`), and a row with no identity is one a replay of this batch will create again.

- BatchCreatePeopleResultDto
  - `created` object[], required: The rows that were created, in submission order.
    - `id` string, uuid, required
    - `accountId` string, uuid, nullable, required
    - `fullName` string, required
    - `title` string, nullable, required: The person's JOB TITLE — nothing else. It is a published field: it is shown to humans as a job title and read as one by everything downstream, so a value like `"VP Sales (UNVERIFIED, sourced from LinkedIn)"` is a wrong job title for every reader of it, not an annotated one. **Sourcing, confidence, dates and "unverified" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo. Omit it and Trayo may fill it for you: a row carrying a LinkedIn identity and no title takes one from the profile Trayo has on file — the current role's title, or the profile headline when there is no current role. Send the field to keep that from happening.
    - `note` string, nullable, required: Free text that is YOURS. Trayo stores it, returns it, and never reads it — it is not parsed, not indexed, not searchable, not shown in the Trayo UI, and nothing in the product branches on it. No structure is imposed: `"sourced from LinkedIn, unverified, 2026-09-02"` is exactly the intended use. Yours, not PRIVATE: it is stored on the person record, so anyone with access to your workspace can read it back. Do not put secrets in it. **This is where provenance, sourcing, confidence and "unverified" go.** Putting them in `title` instead corrupts a published field that humans and downstream systems read as a job title. Up to 1000 characters; longer is `validation_failed`, not a truncation. `null` when you have never set one. It reads back exactly as you wrote it — Trayo does not normalize, trim or rewrite it, and nothing in the product will ever change it.
    - `email` string, nullable, required: The email to use. A value you wrote (here or in the app) always wins; otherwise the one `POST /v1/people/{id}/enrich` found; otherwise null. `emailSource` says which. A found email that later bounced is withheld — see `GET /v1/people/{id}/enrich` for the reason.
    - `emailSource` 'manual' | 'enrichment', nullable, required: `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
    - `phone` string, nullable, required: The phone number a lookup found, or null. Phones are only ever FOUND — there is no way to write one — so this is always a found value. Ask for one with `POST /v1/people/{id}/enrich`, or with `trayo_enrich_phones` on the MCP server.
    - `linkedinUsername` string, nullable, required
    - `linkedinUrl` string, nullable, required: Their LinkedIn profile URL, when Trayo has it. Send it to `POST /v1/contacts/lookup`, `POST /v1/research/person`, or as `linkedinUrl` to `POST /v1/people`.
    - `location` object, nullable, required: Where the person says they are on their LinkedIn profile. Usually their home metro area, but it is the person's own statement, not a verified address, and it is not where their company is headquartered. Null when Trayo has no location for them.
      - `city` string, nullable, required: City, e.g. `San Diego`. Null when the profile names only a region or metro area.
      - `state` string, nullable, required: State, province or region, e.g. `California`.
      - `country` string, nullable, required: ISO 3166-1 alpha-2 country code, e.g. `US`.
      - `raw` string, nullable, required: The location as written on the profile, e.g. `Greater San Diego Area`. Usually present; the only populated field when the profile names just a metro area.
    - `profileImageUrl` string, nullable, required: The stored photo value, as written. Render `photoUrl` instead: it is the same photo under the name every person-returning route uses, with placeholders removed.
    - `photoUrl` string, nullable, required: The person's profile photo, as a directly loadable image URL. Null when Trayo holds no real photo for them — show initials then. Every route that returns a person carries this field under this name.
    - `isRepVisible` boolean, required: Whether Trayo shows this person to a sales rep. Derived by the system and not settable — you cannot send this field, and a value you do send is ignored. A person is rep-visible if ANY of these holds: they are linked to a completed event; Trayo attached them to an account; or somebody deliberately added them, which is what `POST /v1/people` is. **So a person you create here comes back `true`** — creating a person through this API is treated as the same deliberate act as adding one by hand in the app, and it surfaces the same way. What it is NOT: it is not "has an email", and it is not a quality or confidence score. Adding an `email`, a `linkedinUsername` or a `title` neither sets it nor changes it. `false` is what you will see on people Trayo collected while searching but never surfaced — profiles no completed event and nothing else ever picked up. Filter them with `GET /v1/people?isRepVisible=false`; adding one with `POST /v1/people` turns it `true` on the same record.
    - `lastEventAt` string, date-time, nullable, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `stakeholderReport` string, nullable: WHY THIS PERSON MATTERS — the stakeholder report Trayo generates for them: who they are, what they own, and how they relate to what you sell. Markdown, the same report the Trayo app shows on the person, and the same text `POST /v1/people/{id}/ask` reasons over. `null` until stakeholder research has run for this person — it is not produced on create, and a person you added through this API has none until it does. Never an empty string: a blank report reads as `null`, so a caller can test `=== null` and never has to trim. Present on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent. Without it the KEY is absent, which is not the same as `null`.
    - `events` object[]: The events this person is attached to and WHY, most recent `eventDate` first. `stakeholderReport` says why they matter to your workspace at all; this says why they were named on a particular thing that happened. It is the inverse of `people[].reasoning` on `GET /v1/events` — the same links, read from the person. Only events that are themselves public are listed: an event still being processed, or one that was deleted, is not addressable on `GET /v1/events/{id}` and does not appear here either. At most 50 links are listed, the most recent; a person attached to more than that is unusual, and the rest stay reachable through `GET /v1/events?accountId=...` with `expand=people`. `[]` is normal and does not mean nobody thought this person relevant: Trayo links people to an event only once it holds a company profile for the account AND your workspace has a stakeholder definition, and a discovery run you start adds them only with `people: "add"`. See `people` on `GET /v1/events` (`trayo_list_events` on the MCP server). Present on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent.
      - `id` string, uuid, required: Read the full event with `GET /v1/events/{id}`.
      - `title` string, required
      - `eventDate` string, date-time, required
      - `signalType` string, required
      - `state` 'live' | 'dismissed', required: The same two states `GET /v1/events` publishes. `live` is what stands; `dismissed` is an event Trayo produced and then withdrew — the link to this person survives it, so it is listed rather than hidden and this field is how you tell the two apart.
      - `reasoning` string, required: Why THIS person was named on THIS event, written when Trayo attached them. The same text `GET /v1/events` publishes as `people[].reasoning`, read from the other side.
  - `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.
  - `degraded` object[], required: Rows that WERE created (they also appear in `created`) but landed without an account and/or without a LinkedIn/email identity. Always inspect this alongside `created`: a row here is not an error, but it is not a clean import either.
    - `index` integer, required: 0-based position of this row in the submitted array.
    - `id` string, uuid, required: The id of the created row this entry describes.
    - `codes` string[], required: One or both, since they are independent conditions. `no_account` = created with no `accountId`. `no_identity` = created with neither a LinkedIn username nor an email. Whether a replay recreates it depends on the account: with none there is nothing left to match on, so it is created again; inside an account `fullName` is still a dedup key, so the replay is skipped as a duplicate. The `reason` says which case this row is.
    - `reason` string, required: Prose explanation, for a human. Branch on `codes`, not on this.
  - `updated` object[], required: Existing people updated by a LinkedIn or email match, in submission order. `index` maps each record back to the submitted row. These rows do not also appear in `created`.
    - `id` string, uuid, required
    - `accountId` string, uuid, nullable, required
    - `fullName` string, required
    - `title` string, nullable, required: The person's JOB TITLE — nothing else. It is a published field: it is shown to humans as a job title and read as one by everything downstream, so a value like `"VP Sales (UNVERIFIED, sourced from LinkedIn)"` is a wrong job title for every reader of it, not an annotated one. **Sourcing, confidence, dates and "unverified" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo. Omit it and Trayo may fill it for you: a row carrying a LinkedIn identity and no title takes one from the profile Trayo has on file — the current role's title, or the profile headline when there is no current role. Send the field to keep that from happening.
    - `note` string, nullable, required: Free text that is YOURS. Trayo stores it, returns it, and never reads it — it is not parsed, not indexed, not searchable, not shown in the Trayo UI, and nothing in the product branches on it. No structure is imposed: `"sourced from LinkedIn, unverified, 2026-09-02"` is exactly the intended use. Yours, not PRIVATE: it is stored on the person record, so anyone with access to your workspace can read it back. Do not put secrets in it. **This is where provenance, sourcing, confidence and "unverified" go.** Putting them in `title` instead corrupts a published field that humans and downstream systems read as a job title. Up to 1000 characters; longer is `validation_failed`, not a truncation. `null` when you have never set one. It reads back exactly as you wrote it — Trayo does not normalize, trim or rewrite it, and nothing in the product will ever change it.
    - `email` string, nullable, required: The email to use. A value you wrote (here or in the app) always wins; otherwise the one `POST /v1/people/{id}/enrich` found; otherwise null. `emailSource` says which. A found email that later bounced is withheld — see `GET /v1/people/{id}/enrich` for the reason.
    - `emailSource` 'manual' | 'enrichment', nullable, required: `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
    - `phone` string, nullable, required: The phone number a lookup found, or null. Phones are only ever FOUND — there is no way to write one — so this is always a found value. Ask for one with `POST /v1/people/{id}/enrich`, or with `trayo_enrich_phones` on the MCP server.
    - `linkedinUsername` string, nullable, required
    - `linkedinUrl` string, nullable, required: Their LinkedIn profile URL, when Trayo has it. Send it to `POST /v1/contacts/lookup`, `POST /v1/research/person`, or as `linkedinUrl` to `POST /v1/people`.
    - `location` object, nullable, required: Where the person says they are on their LinkedIn profile. Usually their home metro area, but it is the person's own statement, not a verified address, and it is not where their company is headquartered. Null when Trayo has no location for them.
      - `city` string, nullable, required: City, e.g. `San Diego`. Null when the profile names only a region or metro area.
      - `state` string, nullable, required: State, province or region, e.g. `California`.
      - `country` string, nullable, required: ISO 3166-1 alpha-2 country code, e.g. `US`.
      - `raw` string, nullable, required: The location as written on the profile, e.g. `Greater San Diego Area`. Usually present; the only populated field when the profile names just a metro area.
    - `profileImageUrl` string, nullable, required: The stored photo value, as written. Render `photoUrl` instead: it is the same photo under the name every person-returning route uses, with placeholders removed.
    - `photoUrl` string, nullable, required: The person's profile photo, as a directly loadable image URL. Null when Trayo holds no real photo for them — show initials then. Every route that returns a person carries this field under this name.
    - `isRepVisible` boolean, required: Whether Trayo shows this person to a sales rep. Derived by the system and not settable — you cannot send this field, and a value you do send is ignored. A person is rep-visible if ANY of these holds: they are linked to a completed event; Trayo attached them to an account; or somebody deliberately added them, which is what `POST /v1/people` is. **So a person you create here comes back `true`** — creating a person through this API is treated as the same deliberate act as adding one by hand in the app, and it surfaces the same way. What it is NOT: it is not "has an email", and it is not a quality or confidence score. Adding an `email`, a `linkedinUsername` or a `title` neither sets it nor changes it. `false` is what you will see on people Trayo collected while searching but never surfaced — profiles no completed event and nothing else ever picked up. Filter them with `GET /v1/people?isRepVisible=false`; adding one with `POST /v1/people` turns it `true` on the same record.
    - `lastEventAt` string, date-time, nullable, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `stakeholderReport` string, nullable: WHY THIS PERSON MATTERS — the stakeholder report Trayo generates for them: who they are, what they own, and how they relate to what you sell. Markdown, the same report the Trayo app shows on the person, and the same text `POST /v1/people/{id}/ask` reasons over. `null` until stakeholder research has run for this person — it is not produced on create, and a person you added through this API has none until it does. Never an empty string: a blank report reads as `null`, so a caller can test `=== null` and never has to trim. Present on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent. Without it the KEY is absent, which is not the same as `null`.
    - `events` object[]: The events this person is attached to and WHY, most recent `eventDate` first. `stakeholderReport` says why they matter to your workspace at all; this says why they were named on a particular thing that happened. It is the inverse of `people[].reasoning` on `GET /v1/events` — the same links, read from the person. Only events that are themselves public are listed: an event still being processed, or one that was deleted, is not addressable on `GET /v1/events/{id}` and does not appear here either. At most 50 links are listed, the most recent; a person attached to more than that is unusual, and the rest stay reachable through `GET /v1/events?accountId=...` with `expand=people`. `[]` is normal and does not mean nobody thought this person relevant: Trayo links people to an event only once it holds a company profile for the account AND your workspace has a stakeholder definition, and a discovery run you start adds them only with `people: "add"`. See `people` on `GET /v1/events` (`trayo_list_events` on the MCP server). Present on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent.
      - `id` string, uuid, required: Read the full event with `GET /v1/events/{id}`.
      - `title` string, required
      - `eventDate` string, date-time, required
      - `signalType` string, required
      - `state` 'live' | 'dismissed', required: The same two states `GET /v1/events` publishes. `live` is what stands; `dismissed` is an event Trayo produced and then withdrew — the link to this person survives it, so it is listed rather than hidden and this field is how you tell the two apart.
      - `reasoning` string, required: Why THIS person was named on THIS event, written when Trayo attached them. The same text `GET /v1/events` publishes as `people[].reasoning`, read from the other side.
    - `index` integer, required: 0-based position of the row in the submitted array.

## Other responses

- `400`: The request itself is malformed: `people` is missing, not an array, or has more than 200 items (`param: "people"`). A malformed INDIVIDUAL row, or one whose `accountId`/`company` cannot be resolved, 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.
- `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)
