---
title: "Create a person"
method: POST
path: "/v1/people"
tags: ["People"]
---

# Create a person

`POST /v1/people`

## Request

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

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

**A person is a name, a title, a note, an email, a LinkedIn username and a photo URL. That is the whole record you can WRITE.** Phone numbers are only ever found: `POST /v1/people/{id}/enrich` looks up an email and/or a phone and the results appear on the person as `email` (with `emailSource: "enrichment"`) and `phone`.

**`note` is where sourcing and confidence go.** It is free text you own — up to 1000 characters, no structure imposed, stored and returned verbatim and NEVER interpreted by Trayo: not parsed, not indexed, not searchable, not shown in the Trayo UI, and nothing in the product branches on it. `"sourced from LinkedIn, unverified, 2026-09-02"` is exactly the intended use. Set it here, change or clear it with `PATCH /v1/people/{id}`, read it back on every people route.

**Do not put any of that in `title`.** `title` is the job title, it is published, and humans and downstream systems read it as a job title — so a value like `"VP Sales (UNVERIFIED, sourced from LinkedIn)"` is a wrong job title for every reader of it, not an annotated one. That workaround is why `note` exists.

**LinkedIn and email are upsert keys.** A create that matches an existing person on either key updates that row with the fields you sent, makes it rep-visible, returns its existing `id`, and answers `200`. A new person answers `201`. WHERE Trayo looks depends on what you sent: with an `accountId` or a `company`, within THAT account only — the same human under two accounts (an advisor, a dual role) is two records and both are created. With neither, across your whole workspace, because you asked "do you have this person" and the answer must not depend on which account Trayo happens to attach them to. A person with neither key can never upsert. If LinkedIn and email point at two different people, the request is a `409` rather than merging those identities.

**`linkedinUrl` is a source for `linkedinUsername`, not a decoration.** Send either. With no `linkedinUsername`, the username is taken from the URL and stored, so a `POST /v1/find` contacts row keeps its LinkedIn identity when posted verbatim. A `linkedinUsername` you send always wins over the URL.

**For a new person, a LinkedIn identity also FILLS the fields you left empty.** A new row carrying one is looked up in Trayo's own profile mirror, and whatever it finds is used for `title` and `photoUrl` when you sent neither, and for the account link when you sent neither `accountId` nor `company` — so the response can carry a `title`, a `profileImageUrl` and an `accountId` you never supplied, and a company Trayo does not already have becomes a NEW account under the same name-only rule below. It never overwrites a value you DID send, and it is best-effort: a lookup that misses or takes too long creates the row exactly as you sent it rather than failing it. Supply the fields yourself to keep control of them; there is no other way to opt out. A strong-key upsert writes only fields you sent; it never copies these profile values onto the match.

Note `isRepVisible` on the response: it comes back `true`. Creating a person here counts as a deliberate human add, the same as adding one by hand in the app, so the record surfaces to reps. You cannot send the field and you cannot turn it off.

`accountId` and `company` are mutually exclusive ways to link the new person to an account — send at most one, or neither to create an unlinked person.

**Company auto-link.** `company: { name }` finds-or-creates the linked account by `name`, case-insensitive, within your workspace. A match reuses the existing account and `company.domain` is ignored. `company.domain` takes a bare host or a full website URL; the account is created at `https://<host>` either way, and a value that is not a usable hostname is refused — `validation_failed`, `param: "company.domain"`.

A company that does NOT already exist is created anyway — with an empty `url` when you sent no `company.domain`, so a name-only row still gets an account rather than being refused. Supply `company.domain` when you have one so the new account gets a real url. Look up whether an account already exists, and get its `id` for the `accountId` route, with `GET /v1/accounts?q=<company name>`.

## Request body

- CreatePersonDto: **Unknown fields are rejected** (`validation_failed`), not silently dropped. The fields marked "accepted and ignored" are the remaining members of a `POST /v1/find` contacts result, declared so a find result can be posted here verbatim.
  - `fullName` string, required
  - `title` string: 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: 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.
  - `email` string, email
  - `linkedinUsername` string
  - `accountId` string, uuid: Link to an existing account. MUTUALLY EXCLUSIVE with `company` — sending both is `validation_failed` (`param: "company"`). An id that does not resolve in your workspace is `unknown_account`. Send NEITHER this nor `company` and the person is linked to the employer on their LinkedIn profile when the row carries a LinkedIn identity and Trayo has one on file — creating that account if it is new — and is left unlinked otherwise. Send one of the two to decide the account yourself.
  - `company` object: Link by company name instead of id, creating the account if needed. Mutually exclusive with `accountId`. See this object's own description — `domain` is optional, and a company you do not already have is created with an empty `url` rather than refused when you omit it.
    - `name` string, required: Matched case-insensitively against your existing accounts. A match reuses that account and `domain` is ignored.
    - `domain` string: Seeds the new account's `url` when no existing account matches `name`. Omit it freely when linking to a company you already have, or when you have no website at all: the account is then created with no `url`. A bare host (`acme.com`) or a full website URL (`https://acme.com/about`) are both fine: the scheme, `www.`, path and query are dropped and the account is created at `https://acme.com`. A value that is not a usable hostname is refused — `validation_failed`, `param: "company.domain"`.
  - `photoUrl` string: The person's profile image URL. For a new person, omit it on a row that carries a LinkedIn identity and Trayo fills it from the profile it has on file, self-hosting the image; a value you send is kept as-is.
  - `id` string: Accepted and IGNORED — declared only so a `POST /v1/find` contacts result can be posted here verbatim. Never persisted or echoed back.
  - `linkedinUrl` string: A LinkedIn PROFILE URL (`https://www.linkedin.com/in/ada-lovelace`), accepted so a `POST /v1/find` contacts result can be posted here verbatim. **It is USED, not ignored:** when `linkedinUsername` is absent, the username is taken from this URL and stored. `linkedinUsername` WINS when both are present and they disagree — this field never overwrites one you sent. Country subdomains work (`https://il.linkedin.com/in/ada-lovelace`), as does a bare username. A LinkedIn URL that is not a personal profile — a `/company/` page, the feed — yields no username and is IGNORED rather than rejected, so one such row cannot fail an otherwise good import; the person is created with `linkedinUsername: null`. The URL itself is not stored and does not read back: `linkedinUsername` is the field this API keeps, and it is what `GET /v1/people` returns. **For a new person with no strong-key match, it also FILLS the fields you left empty.** Sending a LinkedIn identity and no `title`, no `photoUrl`, or neither an `accountId` nor a `company` makes Trayo look the person up in its own profile mirror and use what it finds for exactly those fields — so the response can carry a `title`, a `profileImageUrl` and an `accountId` you never sent, and a company Trayo has never seen becomes a NEW account (with an empty `url`, like any name-only company). It never overwrites a value you did send, and it is best-effort: a miss, or a lookup that takes too long, creates the new row exactly as you sent it rather than failing it. There is no way to opt out other than by supplying the fields yourself.
  - `state` string: Accepted and IGNORED — declared only so a `POST /v1/find` contacts result can be posted here verbatim. Never persisted or echoed back.

## Response `200`

Existing person updated.

- PersonDto
  - `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.

## Other responses

- `201`: Person created.
- `400`: `validation_failed` covers a malformed body and `accountId` and `company` both present (`param: "company"`). `unknown_account` (`param: "accountId"`) means the `accountId` does not resolve — another workspace's account is indistinguishable from a non-existent one. `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. `unknown_account` (`retry: fix_input`) — One or more `accountIds` do not exist in your workspace. `message` lists them. Create them first or drop them from the request.
- `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.
- `409`: The LinkedIn identity and email match different existing people. Send one identity, or PATCH the intended record explicitly. `conflict` (`retry: fix_input`) — The generic 409: the request collides with existing state, and none of the specific codes covers it. `message` says what.
- `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)
