---
title: "Update a person"
method: PATCH
path: "/v1/people/{id}"
tags: ["People"]
---

# Update a person

`PATCH /v1/people/{id}`

## Request

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

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

Only the fields present in the body change; everything else is left alone.

**`note` is yours to rewrite.** Send a string to replace it (there is no append), send `null` to clear it, omit it to leave it untouched. It is stored and returned verbatim and never interpreted by Trayo, which is what makes it the right home for sourcing, confidence and "unverified" — `title` is the published job title and is the wrong place for any of that.

## Path parameters

- `id` string, uuid, required

## Request body

- UpdatePersonDto: Every field is optional; only fields present are changed. `accountId` and `company` are absent on purpose — an update cannot move a person between accounts.
  - `fullName` string
  - `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, nullable: 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. Send `null` to CLEAR the note; omit the field to leave whatever is there untouched. Sending a string replaces the whole note — there is no append.
  - `email` string, email
  - `linkedinUsername` string

## Response `200`

The person after the update.

- 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

- `400`: Malformed body, e.g. `email` not a valid address. `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.
- `404`: `person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.
- `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)
