# Trayo API — full reference

> Track companies (accounts), define what counts as an event (signals), run discovery and read the events it finds; find companies and people you do not have yet; look up emails and phones; research a company or a person; ask a question about someone you hold; keep working sets in lists.

Base URL: `https://api.trayo.ai`. Version 1.0.0. Also available as OpenAPI 3.1 at https://api.trayo.ai/openapi.json; the short index is at https://api.trayo.ai/llms.txt.

## Overview

**Permissions:** each authenticated REST operation publishes `x-trayo-scope-requirements`
and a readable summary of its required permissions. MCP checks permissions per tool;
read each tool’s `_meta["trayo/scopeRequirements"]`. Read `GET /v1/whoami` for your
key’s configured scopes, current effective permissions and restrictions.
Request validation, workspace access rules and plan eligibility still apply.

Define accounts (companies) and signals (what you want to detect), run
discovery on demand, read the events discovery produces.

**Start with `GET /v1/whoami`.** It confirms the key and returns the
workspace, the plan, `canInitiate` and the lookup
limits — the numbers to plan against before you start. Then pick
a **recipe**: the call sequences that make up a task (track companies and
read events; find lookalike companies; find and keep prospects; stakeholders at a company with
contact details; a brief before outreach; recent movers) are published at
`GET /v1/recipes`, carried in this document as `x-recipes`, and rendered
in `/llms.txt`. Each step names the operation, what it does in that
sequence, and for a poll step the condition that ends the loop. Every
operation below also carries `x-codeSamples` — a `curl` and a Python
sample generated from its own parameters and request schema.

Create or reuse accounts and signals. Then call `POST /v1/discoveries` with the account IDs, signal keys, and lookback window.
Add `people: "add"` when you want the people who matter to each event added and listed on it; by default a run adds no people.

**Give a new account the strongest identity you have.** Send its bare
`linkedinHandle` whenever you know the LinkedIn company page; it is checked
before the website `url`, then the name. A correct handle at create time
makes a `company_not_found` result less likely.

**Discovery is ASYNCHRONOUS.** The POST returns 202 immediately with a run
whose `status` is `running` — it has not searched anything yet. Poll
`GET /v1/discoveries/{runId}` until `settledAt` is non-null, then read
the events standing for its scope with `GET /v1/events?discoveryRunId={runId}`.
That scope is everything the run wrote — at any event date, because a job
change is dated by the month the job STARTED and can predate the lookback —
plus the canonical events matching the run's accounts, signals and lookback
window, including ones earlier runs wrote. Paged to the end, a settled run
therefore yields at least `eventsNew` events — one page is capped and need
not, so follow `nextCursor` before comparing anything to that counter.
Reading it before `settledAt` returns what the run has written so far.

**Run your signals together, not one at a time.** Every event carries
`signalKeys`, the keys of the signals that matched it, so a single run over
many signals stays attributable event by event. Do not split a run per
signal to find out which one fired. `signalType` will not tell you — it is
the kind of source a signal reads (`news`, `jobs`, `job_change`) and your
signals share a handful of those between them.

**`settledAt` is the finish line.** A run has two halves — searching your
accounts, then writing what the search found as events — and it is final
only when both are done. `settledAt` is set at that instant, `status` reads
`completed` or `failed` at the same instant, and from then on the candidate
and event counts are frozen: they are the run's receipt and stay what they
were even if you later delete some of the events it created.

`completedAt` marks only the first half, the search. It can be set while
`status` is still `running`, because the events from that search are still
being written. **Do not poll on `completedAt`, and do not try to detect the
end by watching `eventsNew` stop changing** — a pause between two writes
looks exactly like the end of a run and is not one. Wait for `settledAt`.

`status` is the POLLING signal: it tells you when to stop calling, nothing
more. `candidatesScreened`, `candidatesMatched` and `eventsNew` are the RUN
RECEIPT — what this execution screened, what matched a signal, and what it
wrote — not the size of the scoped events page. Candidates are not events:
`candidatesScreened: 12` with `candidatesMatched: 0` means nothing matched,
and `bySignal` gives both counts per signal, so a signal that matched nothing
shows 0. `eventsFound` is the deprecated name for `candidatesScreened`. Read the page,
`error`, and `blockedSignals`; even a `completed` run with a non-null `error`
ended without doing everything it was asked to.

**A run also says what it could NOT search.** `blockedSignals`, on the 202
and on every poll, names each ready signal that cannot produce results for
part of the run, why, and — in `accountIds` — WHICH accounts. Read it before
concluding from `eventsNew: 0` that there was nothing to find: an account
that was not searched is indistinguishable from a real empty result on the
counts alone.

You can use a new signal key in discovery immediately.
These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.

**Re-running an identical discovery is safe.** Results are deduplicated
against everything already in the workspace, so calling the same
accounts/signals/lookback again typically returns `eventsNew: 0` and creates
no duplicate events. The `discoveryRunId` event read still returns the
matching events that already stand for that scope. Zero is SUCCESS, not
failure, and not a reason to retry. A retry loop that treats `eventsNew: 0`
as an error will never terminate.

**Errors** are always `{ "error": { type, code, retry, message, param,
requestId } }`. `code` identifies the reason from the documented set.
Every failure includes a code, including request validation failures.
`retry` tells you what to DO about it without parsing the message:
`fix_input` (change the request), `retry_later` (send the same request again
shortly, with backoff and a FINITE number of attempts), `do_not_retry` (it
will never work). Branch on `code`; fall back to `retry` when you only need
to decide whether to send the request again, or when you meet a `code` you
do not recognise — that fallback is what lets a client written today keep
working against an error added tomorrow. The full table, with every code's
status and retry class, is in the `Error` schema.

Two 409s make the point: `idempotency_in_progress` and
`idempotency_key_reused` share a status and demand OPPOSITE responses —
repeat the identical request with the same key, versus never send that
request again unchanged. Never infer recovery from the HTTP status.

**Rate limits.** Every route shares the workspace budget, in two buckets, each a
rolling 60-second window. A workspace starts with 120
requests per minute for the API and a separate
7 per minute for the routes
that do the most work per call, `POST /v1/discoveries`, `POST /v1/find`, `POST /v1/accounts/lookalikes`, `POST /v1/companies/competitors`, `POST /v1/posts/search`, `POST /v1/posts/search/keywords`, `POST /v1/research/person/batch`, `POST /v1/stakeholders/search/batch` and `POST /v1/people/enrich/batch`; a call spends one
bucket or the other, never both, and each MCP tool names the bucket it spends in its description and
`_meta["trayo/rateLimitBucket"]`. REST, MCP, all API keys and OAuth users share
the same workspace buckets; adding credentials does not increase throughput.
Support can adjust these defaults for a workspace, so read your budget rather than
hardcoding these numbers: EVERY authenticated response, successful ones included, carries
`RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds
until the window frees up) for the bucket that request was counted in. That
is how you slow down BEFORE being refused. Over the limit is
`429 rate_limited` with `Retry-After` in seconds: wait that long, then send
the same request again.

**Fanning out is what actually hits this** — a hundred concurrent
calls to any route are a hundred requests in one window, not one — and
**`GET /v1/whoami` is limited like everything else**, so a
balance poll on a timer spends the same budget as the work it is watching.
Back the poll off with the rest of your client, or drop it: the
`RateLimit-*` headers on the responses you are already getting say more,
sooner, for nothing. A per-network limit also sits in front of every `/v1`
route, before the key is read: a burst from one network can be refused
there, and that 429 carries `Retry-After` and no `RateLimit-*`. It is the
only limit on the unauthenticated document routes under `/v1` — fetch the
document once and reuse it.

**Usage limits.** Workspace usage enforcement is configurable. When it is off,
usage is metered without balance cutoffs, daily caps or lookup quotas.
When it is on, configured usage limits apply. Lookup allowances
in `GET /v1/whoami` (MCP: `trayo_whoami`) are `null` when no cap applies.
Workspace request pacing, authentication, permissions, lifecycle and validation still apply.

**Pagination.** Six list routes — `GET /v1/accounts`, `GET /v1/people`,
`GET /v1/events`, `GET /v1/discoveries`, `GET /v1/lists`,
`GET /v1/lists/{id}/members` — return `{ data, hasMore,
nextCursor }`; follow `nextCursor` until `hasMore` is false.
`POST /v1/find` also paginates when you send only `filters`: repeat the
same request with `nextCursor` as `cursor`, preserving the filters, until
`hasMore` is false. Any search with a `query`, including one that also
sends `filters`, returns one ranked page and rejects a `cursor`.
`GET /v1/signals`, `POST /v1/stakeholders/search` and the research routes
do not paginate. `GET /v1/signals` carries the list envelope with
`hasMore` always false. `POST /v1/job-changes/search` pages with its own
`limit`/`cursor`/`nextCursor` on the body, not on the query string, and
only when you send `destination`.

**Naming across routes:** two things go by different names depending on
the route, and nothing is renamed for you — map them at your boundary.
The **company website** is `url` on an account, `domain` on the `company`
block of `POST /v1/people`, and `website` on the routes that take a company
reference (`POST /v1/research/company`, `POST /v1/stakeholders/search`,
`POST /v1/job-changes/search`, `POST /v1/contacts/lookup`); a
`POST /v1/find` accounts row carries both `domain` and `url`. The
**LinkedIn page** is a bare handle in an account's `linkedinHandle` and in
a person's own `linkedinUsername`, and a full URL in `linkedinUrl` on the
company references and on find results. Different names, the same company
website and the same LinkedIn page.

**Prospecting:** `POST /v1/find` searches for companies and people.
For direct stored-data search, send `mode: "direct"` with exact `filters`
and a limit up to 100. For people, company filters select their current
employer and `filters.title` selects the person; each row includes both.
Direct mode does not accept a sentence query or automatically expand criteria.
A company without a website is still returned; use its ID for follow-up searches.
Check `truncated` and `notes`; per-company limits can leave additional people unseen.
`POST /v1/posts/search/keywords` directly searches stored LinkedIn posts:
`concepts` is an AND of groups, each containing OR phrase alternatives.
This supports explicit query decomposition and synonym expansion in one call.
Its results rank keyword relevance within a bounded sample of 200 ranked candidates, with coverage even when empty.
It searches at most seven days of the recent-post index (bodies of at least 120 characters).
Retrieval is always non-exhaustive; an empty result cannot prove absence.
Direct searches use an eight-second request budget and shared capacity limits.
On `data_search_busy`, wait for `details.retryAfterSeconds`; on
`data_search_timeout`, narrow the criteria. Neither error means no matches.
`POST /v1/accounts/lookalikes` finds companies similar to supplied seeds.
Both return candidates without saving them — you post selected results
back to `/v1/accounts/batch` or `/v1/people/batch` to bring them in.
`POST /v1/companies/competitors` ranks the competitors of one company.
Find with a sentence query draws on your search quota per search that runs; contact lookups (below)
draw on your plan's lookup allowance. Research, stakeholder search and
asking about a person draw on the same usage quota per answer returned.
Nothing else in this API is metered.

**Working sets:** `/v1/lists` is where the set of people or accounts you
are acting on lives between calls. Create one, add members by `personId`
or `accountId` with a `via` that records how they got there (`find`,
`signal`, `import`, `manual`), then hand the LIST — not your session — to
whatever acts on it: a CRM push, a campaign, a human. Adding a member who is
already there is a skip, not an error, so the same batch is safe to send
twice. Lists are scoped to the user your key acts as, and show up in the
Trayo app under that user. Do not mark a working set with `starred`, a
person's `note`, or a marker in `title` — that is what lists are for.

**Ranking:** every account carries `scores` — Trayo's own view of how
much is happening there — so you do not have to rebuild a ranking by
counting events. `scores.timing.score` (0–100, normalized against your
workspace's hottest account, as the product defines it) is the number for
"who is worth working this week"; `scores.timing.raw` is the un-normalized
value that holds still for absolute thresholds;
`scores.activity` adds the event count, average significance and relevance,
and a generated prose `summary` of what the events add up to. Both are
null until Trayo has scored the account, and nothing in this API
triggers scoring. `GET /v1/accounts` does not sort by them yet: page
through and sort client-side.

**Contact enrichment:** `POST /v1/people/{id}/enrich` finds a person's
email and/or phone the same way the Trayo app's own "Find email" does,
and `POST /v1/people/enrich/batch` accepts up to 200 ids per call. Its
45-second budget for starting lookups is checked once, before anything is
reserved; the whole batch starts together in a few seconds. The batch answers `200`
with partial results. Retain the submitted ids: `skipped[].index` maps to
that request. Re-send rows skipped because time ran out in smaller batches;
nothing was started or charged for them. Other skip reasons, including
allowance refusals and invalid ids, require their own remedy. Single-person
lookup requests answer `202`; both routes use `GET /v1/people/{id}/enrich` to poll.
These lookups are
explicit (nothing is looked up on create or read), asynchronous, idempotent
(re-sending never double-runs or is charged twice — a field already
running, already found, blocked by an email you wrote, or inside its retry
cooldown is reported in `skipped`, not raised), and governed by your plan:
each lookup counts against the
plan's lookup limit (`402 lookup_limit_reached` before anything runs).
Every found value carries
`source`, `status` and `foundAt`; a value you wrote is `source: "manual"`
and is never overwritten by a found one.

**Workspace settings:** `PATCH /v1/workspace` sets your stakeholder
definition — plain prose describing who buys and owns what you sell, and
who to skip — plus `solutions` and `solutionsBrief`, what you sell. The
definition steers stakeholder search and the per-event people (with
`reasoning`) that a discovery run started with `people: "add"` attaches;
the default, `people: "none"`, attaches none. Without it stakeholder search seeds
on seniority alone and no people are attached to events. **Set it FIRST,
before you run discovery** — it applies to work
that starts after it is set, not retroactively. The definition is carried as
`stakeholderCriteria` on the workspace and as `definition` on stakeholder
search.

**Stakeholders:** `POST /v1/stakeholders/search` takes a company — any
company by its website, or an account of yours — and a stakeholder definition,
and returns the people at it who fit, best first, each with a reason.
Synchronous, seconds, read-only: nothing is written, import the ones you
want with `POST /v1/people/batch`. It is the product's own stakeholder
finding (definition → keywords → candidate search → selection) without the
persistence, so it works for a company you have never touched. Omit the
definition to use the workspace's; the response says which applied.
Each person carries `matchedPersona` and each company `coverage` (every
persona, whether anyone matched it, and `candidates`: how many people its own
title search found, 0 meaning nobody there holds such a role); each label is checked against the
job's function and seniority. When nobody fits, `nearMisses` holds the
closest candidates flagged `belowThreshold: true`. Repeating a search
within a week normally returns the same people. For many companies,
`POST /v1/stakeholders/search/batch` takes up to 50 in one call and searches
them ten at a time, reporting each in `results` or `errors` by `index`.

**Recent moves:** `POST /v1/job-changes/search` finds people who changed
company. Send `source` (the company left), `destination` (the company
joined), or both. Without a company, send `title` (the new role, same
terms as `/v1/find` `filters.title`), optionally with `destinationFilters`
(`hq.countries`, `headcount` of the company joined). Inclusive `startedSince` and
exclusive `startedBefore` bound the start month for either kind of search. `startedSince` (the
month the new role started) narrows either kind of search: "who became
CISO since August at US companies over 500 people" is one call, and so is
"who joined Stripe as an executive since September". It returns moves
observed in the last 90 days — the person, the two companies, the title on
each side, when the move was detected, and when the new role started to the
month — and writes nothing. One row per person per destination.
`limit` sets the page size: 25 by default, 100 at most, capped rather than
rejected. `hasMore` says more matches may remain behind this page; send
`nextCursor` back as `cursor`, verbatim and with the same search, to walk
them. The walk resumes where the page stopped, so a later page neither
repeats nor skips what an earlier one returned. `hasMore` reads the row
count before duplicate records are collapsed, so a page can come back
short and still be `true` — follow `hasMore` and `nextCursor`, never the
row count. A source-only search may read a fixed sample rather than an ordered
walk: only that sampled search cannot page, and sending a cursor is rejected.
Indexed source-only searches accept cursors. For a sampled source-only search,
`nextCursor` is null, and `hasMore: true` means narrow the
search. Report truncation
for the requested period. `detectedSince` filters detection recency: only moves with
`detectedAt >=` the value you send — the date the move was DETECTED, not the
date the role started, which startedSince and startedBefore filter. It only narrows the 90-day
window, starting at midnight UTC 90 days ago: an earlier value is clamped to
that start and a future value matches nothing. Destination searches already return
the newest moves first: narrowing the window does not recover omitted matches;
it returns the same rows or a subset. This is a recency filter, not pagination.
`seniority` is the second filter: it keeps only moves INTO a role at that
level or above, read from the title of the role they joined — not the one
they left, and not a title list you supply. The four seniority levels widen
in order: `cxo` (the C-suite), `vp` (adds VP, SVP, EVP, Vice President and
President), `director` (adds Director), `head` (adds Partner, Principal and
Head of). With `destination`, it WIDENS the search rather than trimming the page — the search
reads further through recent moves looking for matches instead of filtering
the slice it would have read without it — and that wider read is still
bounded, so a short or empty answer does not prove nobody senior joined.
Source-only searches that report `sampled: true` filter the same fixed sample; they do not read further. Title matching is heuristic and excludes assistant/support roles.
`sampled: true` means a source-only search read a small
fixed sample of recent moves: an empty answer does not mean nobody left,
and retrying gives the same answer while the underlying data is unchanged.
A source-only search with `sampled: false` walked the newest moves out of
that company instead of sampling them.
With `destination`, `sampled` is false: the search reads a bounded slice of
the newest moves into that company before filtering by source.
`hasMore: false` does not prove completeness with either value of `sampled`.
Every row is a move between two companies; promotions and same-company
title changes never appear here. For promotions, and for checks you repeat on a
company over time, use the workspace `job_change` signal (`create_signal`, then
`run_discovery`). That signal is not a departure search and does not provide
a complete list of who left. Import the people you want with
`POST /v1/people/batch`.

**Research:** `POST /v1/research/company` and `POST /v1/research/person`
return the quick overview the Trayo app opens its drawer with — for a
company: what it does, `whyNow` hooks from its recent updates, and `fit`
against what you sell; for a person: career, likely challenges, and the
angles that tie their role to what you sell. Synchronous, stateless,
read-only: one read of Trayo's own data, one generated answer, nothing stored,
nothing written, any company or person we hold — not only the ones in your
workspace. Both read your `solutions` and stakeholder definition from the
workspace for the seller side of the answer.

`POST /v1/research/person/batch` is the person route for up to
10 people in one call, always ungrounded, reading your seller
context once for the whole batch. A person it cannot resolve or answer for
is reported in `errors` by its `index`, with the same `code` and `retry` an
error response carries, so one bad profile never costs you the rest of the
batch. An overview that came back empty is one of those errors
(`person_profile_too_thin`), never a blank result.

**Contact lookup without a person:** `POST /v1/contacts/lookup` takes a name
plus a LinkedIn profile or a company website and returns that person's email
in the same response, for somebody who is not in your workspace and does not
have to be. Ask for `fields: ["phone"]` and it answers `202` with a
`lookupId` to collect from `GET /v1/contacts/lookups/{lookupId}` — the phone
lookup can take several minutes. One lookup per
field, against your plan's allowance for that field — email and phone have
separate limits, and a lookup here draws on the same allowance as a lookup
on a person. Asking again for the same person reads the stored answer for
nothing.

**Ask about a person:** `POST /v1/people/{id}/ask` answers one question
about one person in your workspace from everything on record — the Trayo
record (contact details, the account and its research, the signals the
person is attached to and why, the sequences and emails sent with their
outcomes, lists, notes) plus the LinkedIn profile Trayo holds for them
(work history, recent posts). Send an `intent` — what you will do with the
answer — to steer focus and form. Synchronous, seconds, read-only,
grounded in that dossier only: nothing is searched, nothing is guessed,
and `sources` is a receipt of what Trayo read. Each answer draws on
your usage quota.

**Which of the two to ask.** `POST /v1/research/person` is a FIXED overview
of ANY person Trayo holds, built from their public profile — use it for
someone you have just found and do not have. `POST /v1/people/{id}/ask`
takes YOUR question about a person you already hold, and is the only route
that can see your own history with them: what discovery attached to them,
what you sent, whether they replied. "Who is this person" is research;
"did we ever email her, and is she worth a sequence" is ask.

**Limits go two ways, on purpose.** `POST /v1/find`'s `limit` CLAMPS at 50.
A `/v1/…/batch` array over its cap is an ERROR — 200 items on the write
batches, 10 on `POST /v1/research/person/batch`. Silently dropping
batch rows would lose records you believe you created; rejecting an over-large
find `limit` would draw on your search quota for a number you could have read
here.

**Auth:** every route below requires `Authorization: Bearer <key>` except
a few unauthenticated discovery routes, so a caller can find and read
this document before it has a key configured: `GET /` and `GET /v1` return
a small JSON index naming the document and how to authenticate, and this
document itself is served at `GET /openapi.json`,
`GET /.well-known/openapi` and `GET /v1/openapi.yaml`. All three are the
SAME document; the extension picks the default serialization and an
`Accept` header of `application/json` or `application/yaml` overrides it.
The same reference as Markdown is at `GET /llms.txt` (a short index) and
`GET /llms-full.txt` (everything, in one file); `GET /v1/recipes` is
unauthenticated too.

## Recipes

The call sequences that make up a task. Each names the operations below in order; a step marked *poll* is repeated until its stop condition holds, with backoff and a finite cap. The same list, as JSON, is at https://api.trayo.ai/v1/recipes.

### First three calls with a new key (`start-here`)

**When:** You have a key and nothing else. Confirms the key works, learns which workspace and plan it acts in, and sets the two settings every stakeholder-finding route reads.

**Spends:** Nothing.

1. `GET /v1/whoami` (`whoami`) — Confirms the key, and returns the workspace, the plan, `canInitiate`, and the lookup limits. A 401 here means the key is wrong; nothing else in this API will work either.
2. `GET /v1/workspace` (`getWorkspace`) — Read `stakeholderCriteria` and `solutions`. If both are null, stakeholder search seeds on seniority alone and discovery attaches no people to events, even on a run started with `people: "add"`.
3. `PATCH /v1/workspace` (`updateWorkspace`) *(optional)* — Set them, in prose, BEFORE you run discovery — they steer work that starts after they are set, not retroactively.
4. `GET /v1/recipes` (`listRecipes`) *(optional)* — Pick the recipe for the task at hand from this list.

### Run discovery for companies and read their events (`discover-account-events`)

**When:** You have a list of companies and want events — news, hiring, job changes — matched against signals you define. This is the core loop of the API. Import accounts and create signals once, then save the account IDs and signal keys. To automate later checks, have a scheduler you control call `POST /v1/discoveries` with those saved values on each execution, wait for the run to settle, and read its events. Saving accounts or signals does not schedule a run in an API-only workspace.

**Spends:** With usage enforcement on: Each account you import draws on your workspace quota, once for the month it is added and again for each month it stays; a duplicate row draws nothing. Signals and discovery runs are not metered, but a workspace whose quota is used up cannot import or start a run (`quota_exceeded`). With it off, usage has no quotas. 

1. `POST /v1/accounts/batch` (`batchCreateAccounts`) — One-time setup: save `created[].id` and duplicate `skipped[].existingId` for later runs. Up to 200 per call. `url` must carry a scheme. Supply the bare `linkedinHandle` whenever known: it is the strongest way to identify the company and is tried before the website and name. Every account created here is assigned to the user your key acts as, which is what makes it eligible for discovery.
2. `POST /v1/signals` (`createSignal`) — One-time setup: define what counts as an event, save its key, and reuse that key on later runs instead of creating the same signal again. `news` searches the web. `jobs` and `job_change` search hiring data.
3. `POST /v1/discoveries` (`startDiscovery`) — Send a new `Idempotency-Key` for each intended run. Reuse that key with the same request body only for retries of that run; a later scheduled execution needs a different key even with the same accounts and signals. Add `people: "add"` if you want people attached to the events; the default adds none. The response returns 202 with `status: running`. Poll this run until `settledAt` is set. Then read `blockedSignals` and `error` before reporting results. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.
4. `GET /v1/discoveries/{runId}` (`getDiscovery`) *(poll)* — `settledAt` says when to stop polling — the run is searched AND its events are written, and the counts are frozen from then on. `candidatesScreened` (deprecated name `eventsFound`), `candidatesMatched` and `eventsNew` describe this execution; they are not the size of the scoped event page. `error` non-null on a completed run means it did not do everything it was asked. `eventsNew: 0` on a re-run is success: results deduplicate against the workspace. Do not poll on `completedAt` — that is the end of the search, not the end of the run.
   Until: `settledAt` is non-null (`status` reads `completed` or `failed` then too). Poll every few seconds with backoff and a finite cap; never a tight loop.
5. `GET /v1/events` (`listEvents`) — Filter by `discoveryRunId` for everything this run wrote, at any event date, plus the events standing for its accounts, signals and lookback window, including ones earlier runs wrote. This page can be non-empty when `eventsNew` is zero, and once the run settles, paging to the end collects at least `eventsNew` events — one page is capped and need not. Read it before settlement for what has landed so far. Follow `nextCursor` until `hasMore` is false. Keep `discoveryRunId` on every page. For repeated new-event digests, record `checkStartedAt` before each run; after settlement, read each saved `accountId` with `signalKeys` and `discoveredSince` set to the last successful checkpoint instead of reusing an old `discoveryRunId`. Page to the end, deduplicate event IDs, and suppress IDs delivered by earlier successful checks: the timestamp boundary is inclusive, so an event can match two checks. After all reads and digest output succeed, persist the newly delivered IDs and advance the checkpoint to `checkStartedAt` together. If a read or output fails, leave both unchanged. Every event names the signals it matched in `signalKeys` — so run all your signals together and attribute each event afterwards, rather than one run per signal. `signalType` is only the kind of source (`news`, `jobs`, `job_change`) and several signals share one. To go the other way, `?signalKey=` filters the list to one signal. Send `expand=signals` only for the per-match `confidence` and `evidence`; the keys are already there.

**Then:** Rank by `scores.timing.score` on each account rather than counting events, and put the people worth acting on in a list (`prospect-to-list`).

### Find companies like these (`lookalike-companies`)

**When:** You have example companies and want a ranked list of similar companies.

**Spends:** Nothing. This search is not metered.

1. `POST /v1/accounts/lookalikes` (`findLookalikeCompanies`) — Send {"companies":[{"linkedinUrl":"https://www.linkedin.com/company/stripe"}],"limit":100}. Each of the 1–50 seeds can carry companyId for an exact company reference, or linkedinUrl, website and name for matching in that order. companyId accepts a number or numeric string; it takes precedence when supplied and is not a workspace account UUID. The limit is the total number of results wanted, up to 500. One response, no pagination.

**Then:** Read seeds to check matched identities and any not_found or not_indexed inputs. Reuse companyId from a matched seed or result in a later search. The companies come best match first, each with a preliminary high, medium or low similarity label. filtered counts the seed subsidiaries, repeat listings and companies outside the seeds' employee range that were left out; send matchSize false to allow any size. Results also rank higher when people at your workspace's accounts used to work there; send useWorkspaceAccounts false to rank on the seeds alone. Nothing is saved. To save selected companies, use batchCreateAccounts with name and a website url including https://, in batches of 200. Also send linkedinHandle from linkedinUrl when available; omit rows without a name or usable website.

### Rank a company's competitors (`competitor-roster`)

**When:** You want a short, ranked list of who a company competes with, with the reason for each row.

**Spends:** Nothing metered. Generation typically takes 10 to 25 seconds. Allow more than 120 seconds for the client timeout. A repeat call with the same pins returns the stored roster at once.

1. `POST /v1/companies/competitors` (`generateCompetitorRoster`) — Send {"company":{"self":true}} for your own workspace, or {"company":{"website":"qodo.ai"}}. Add pins for competitors you already know, most important first; the first pin is rank 1. cap defaults to 15, maximum 50.

**Then:** Read subject.indexed: false means no similarity data was available for this company, so the roster came from your pins and the companies named while it was generated. Each entry carries source (pool, pin, nomination), rankReason (pinned, incumbent, alignment), the verdict and both alignment scores. unresolvedNominations lists names that matched no company in our data. A repeat call with the same pins and an equal or smaller cap answers from the stored roster with reused: true; send refresh: true to build a new one.

### Find companies or people you do not have, and keep the good ones (`prospect-to-list`)

**When:** You are starting from a description ("Series B fintechs in Europe", "heads of data at logistics companies") rather than from records you already hold.

**Spends:** One search against your quota per `POST /v1/find` that RUNS — a search that ran counts whether or not its results were useful, so read the response before sending a second one. Each account you then import draws on the same quota, as on `batchCreateAccounts`.

1. `POST /v1/find` (`find`) — `target` plus a natural-language `query`; add `filters` to make any criterion exact. One ranked page of at most 50. Read-only: nothing is written by finding. Before you re-run a disappointing search, read the three fields that say why it was thin. `interpreted` is what Trayo actually read out of your sentence — a criterion missing from `interpreted.filters` was NOT enforced, and resending `interpreted.filters` as `filters` makes the search exact and reproducible. `truncated: true` means more matched than this search can show, so narrow rather than page deeper. `degraded: true` means part of the search failed or timed out, so a thin page may be an outage rather than an absence of matches; `notes` then carries a `search_degraded` entry, which is the only account of what went wrong. Use what came back or refine what you send — do not just spend another search on the same words, because a repeat is billed as a new search and some causes persist for a workspace and degrade every repeat. An unqualified query is the usual cause of a thin page: "GPU cloud and AI compute providers" comes back dominated by companies over 27,000 people, because nothing in it says how big; say "startup", or send `filters.headcount`, and the same search returns the companies you meant. (`droppedUnaddressable` counts rows removed for having no website at all — a large one means the sentence swept up directories and institutions.) `notes` is the fourth, and the one to open whenever any of the other three says something went wrong: it names a filter whose values were not all scanned to the end, a bare city name that turned out to mean several places, or the degradation `degraded: true` reports. A bare city is `"dublin"`, Ireland and Ohio, or `"newark"`, New Jersey and California. Pin one as `"newark|california"` or `"dublin||IE"`. Several values of one filter are a union, so one call with twenty industries returns what twenty single-industry calls return; you do not need to split a search unless `notes` says a value ran out of budget.
2. `POST /v1/accounts/batch` (`batchCreateAccounts`) *(optional)* — Bring the companies in. Deduplicated on hostname, then on `linkedinHandle`; a repeat is `alreadyAdded`, not an error.
3. `POST /v1/people/batch` (`batchCreatePeople`) *(optional)* — Bring the people in. A person needs an account to belong to, so create accounts first.
4. `POST /v1/lists` (`createList`) — A named working set that outlives this session and shows up in the Trayo app under the user your key acts as.
5. `POST /v1/lists/{id}/members` (`addListMembers`) — By `personId` or `accountId`, with `via: "find"` recording how they got there. Re-adding a member is a skip, so the same batch is safe to send twice.

**Then:** Hand the LIST to whatever acts on it — a CRM push, a campaign, a person — rather than your session's memory of it.

### Find the right people at one company and get their contact details (`stakeholders-at-a-company`)

**When:** You know the company and need the people who own or buy what you sell, with an email or phone to reach them.

**Spends:** One use of your usage quota per company overview and per stakeholder search that returns. One lookup per field started (email, phone), found or not, against the plan's allowance — check `lookups` on `GET /v1/whoami`.

1. `POST /v1/research/company` (`researchCompany`) *(optional)* — A few seconds: what the company does, `whyNow`, and `fit` against what you sell.
2. `POST /v1/stakeholders/search` (`searchStakeholders`) — Any company by website, or an account of yours. Synchronous, read-only, best first, each with a reason. Omit `definition` to use the workspace's.
3. `POST /v1/people/batch` (`batchCreatePeople`) — Import the ones you want; a person must belong to an account, so create it first if needed.
4. `POST /v1/people/{id}/enrich` (`enrichPerson`) — Explicit, asynchronous (202), idempotent. Re-sending never double-runs or spends twice; `skipped` says why a field was not started.
5. `GET /v1/people/{id}/enrich` (`getPersonContact`) *(poll)* — Free to read. Each field is `in_progress`, then `found` or `not_found`.
   Until: No field is `in_progress`. Poll every few seconds with backoff and a finite cap; never a tight loop.

### One person's email or phone, without adding them to the workspace (`contact-without-importing`)

**When:** You have a name and a LinkedIn profile or a company website, and want contact details for someone who does not need to become a record.

**Spends:** One lookup per field, against the same allowance as a person-bound lookup.

1. `POST /v1/contacts/lookup` (`lookupContact`) — `fields: ["email"]` answers in the same response. Ask for `phone` and it answers 202 with a `lookupId`: a phone lookup takes minutes. Include the known employer `company.website` even with a LinkedIn profile: without it, a matching name can produce `match` at the wrong employer. Read `email.identityConfidence` before outreach; a `conflict` also reads `email.status: "unconfirmed"`. Hold every `conflict` for human review; the field does not block sending. `match` does not prove mailbox ownership or current employment, and `unknown` is inconclusive.
2. `GET /v1/contacts/lookups/{lookupId}` (`getContactLookup`) *(poll, optional)* — Collect the phone result. Asking again for the same person reads the stored answer for nothing.
   Until: The lookup is no longer in progress. Poll every few seconds with backoff and a finite cap; never a tight loop.

### What to know about a person before writing to them (`brief-before-outreach`)

**When:** You are about to reach out and want the angle, the likely challenges, and your own history with them.

**Spends:** One use of your usage quota per overview or answer returned. A call that fails costs nothing.

1. `POST /v1/research/person` (`researchPerson`) *(optional)* — For someone you have just found and do NOT hold: a fixed overview of ANY person Trayo holds, from their public profile. `grounded: true` adds a web search and takes longer.
2. `POST /v1/people/{id}/ask` (`askPerson`) *(optional)* — For someone you already hold: YOUR question, answered from the Trayo record plus the profile — the only route that can see what discovery attached, what you sent, and whether they replied. Send `intent` to steer the form of the answer. Capability: people:read includes derived briefing context, including linked account research, signals, outreach history and workspace prose. Answers can include those facts without separate source scopes. The key and its owner's current workspace role must both allow people:read; losing unrelated owner permissions does not block this operation. Check key.effectiveScopes in GET /v1/whoami. Direct account and workspace reads still require accounts:read, and direct event reads events:read.

**Then:** "Who is this person" is research; "did we ever email them, and are they worth a sequence" is ask. Use one or the other, not both for the same question.

### Companies and people by exact criteria (`exact-prospecting`)

**When:** Your target is a WHERE clause — an industry, a headcount band, a country, a founded year, a title — and you want all available matches, paginated. Same route as `prospect-to-list`. send `filters` instead of (or as well as) `query`. Recorded funding rounds stopped updating in November 2025 and are not a filter. For companies that raised money, use a discovery `news` signal, such as `"detects": "The company raised a funding round"`.

**Spends:** Nothing for a filters-only search. One search against your quota per search with a `query`, counted whether or not the results were useful. Each account you then import draws on the same quota.

1. `GET /v1/find/industries` (`listFindIndustries`) — Industry is a closed taxonomy. Pick exact values here (`?contains=software`) — an unlisted value matches nothing.
2. `POST /v1/find` (`find`) *(optional)* — `target: "companies"` with `filters`. At least one of industries / hq.cities / hq.states / headcount. Page with `nextCursor`; if `truncated` is true, narrow the filters instead of paging deeper. The same three diagnostics apply here as in `prospect-to-list` — `interpreted`, `truncated`, `degraded` — and on a filters-only search `interpreted` is null and `degraded` always false, because nothing was interpreted and the sentence search did not run.
3. `POST /v1/find` (`find`) — `target: "people"` with the same company filters plus `filters.title`. To search inside companies you have already picked, name them instead: `filters.companyWebsites` with the `website` each row carried — the form you have without asking this API for anything. Other company filters narrow only these companies. Without `query`, read `resolvedWebsites` in the answer to see which company each one became before you trust the rows, and `unresolvedWebsites` for the ones left out of the search: `not_found` (no company on the domain) or `uncertain` (several possible companies, listed in `alternatives`; send the right one's `id` as `filters.companyIds`). A `404` only when none resolved. `perCompany` bounds how much one employer contributes. With a `query`, company filters instead check each ranked person's current employer; people whose employer cannot be checked are left out, and `query_page_filtered` explains a short page. That sentence-search page is not refilled.
4. `POST /v1/accounts/batch` (`batchCreateAccounts`) *(optional)* — Track the companies: each row carries `name` and a ready `url`.
5. `POST /v1/people/batch` (`batchCreatePeople`) *(optional)* — Import the people you want to work; `linkedinUrl` carries over.

**Then:** Contact details as in `contact-without-importing`; a brief as in `brief-before-outreach`.

### People who just changed company (`recent-movers`)

**When:** A champion left a customer, or a competitor's people are moving — you want who moved where in the last 90 days. Or you want everyone who recently started a role, such as a new CISO, at any company.

**Spends:** Nothing to search. One lookup per contact field you then look up.

1. `POST /v1/job-changes/search` (`searchJobChanges`) — `source` (the company left), `destination` (the company joined), or both. Or, with no company, `title` plus `destinationFilters`. `startedSince` keeps roles started since a month, with or without a company. Up to 25 moves, read-only. A source-only search may read a bounded sample (`sampled: true`), so it is not exhaustive.
2. `POST /v1/people/batch` (`batchCreatePeople`) *(optional)* — Import the movers you want to work.
3. `POST /v1/people/{id}/enrich` (`enrichPerson`) *(optional)* — Then contact details, as in `stakeholders-at-a-company`.

### Each run: new companies, their recent signals, a few people each, their emails (`signal-triggered-prospecting`)

**When:** Before you write any code, ask the user every question in `ask` and wait for the answers. If you cannot ask, start your reply with your guess for each one, then build. You are building an app or script that, when the user starts it or their own scheduler triggers it, takes a batch of companies the workspace does not hold yet, checks them for signals over a recent window, and returns a few people at each company that fired, with work emails. The chain is find, add companies, discover, read events, search stakeholders, add people, look up emails.

**Spends:** Nothing for a filters-only find, discovery, events or stakeholder search. A find with a `query` is one search against your quota, counted whether or not its results were useful. Each email looked up is one lookup against `lookups.email` on `GET /v1/whoami`, found or not: 50 companies with 3 people each can draw 150, so read `remaining` first and size the batch to it.

**Ask first, before any code** (wait for the answers, or state your guess for each):

- Trigger: does the user start each check, or will a scheduler they control call the API? Trayo does not schedule discovery for API-only workspaces. Keep the API key on the backend that runs the job.
- Destination: where do results go: the app itself, a spreadsheet, or a CRM such as HubSpot?
- Batch size: how many companies to CHECK per run? Default to about 50 per run, not a whole market in one call. Only some of them will show the signal, so say so up front rather than promising a number of hits.
- Where it is built: build it where you can keep editing and re-running it. Do not hand it off to a hosted app builder unless the user asks; iterating there loses the loop with this API and these recipes.
- Brand: use the user's logo and color palette by default. Ask for them, or read them off their website.

1. `GET /v1/whoami` (`whoami`) — The app needs a workspace API key. The user creates their own on the API keys page of the Trayo web app (any workspace member can, and the key gets at most their own permissions), or reuses the key their MCP connection uses. Never ask the user to paste the key into the conversation: have them put it in a secret file or environment variable the app reads, such as `TRAYO_API_KEY` in `.env`, and keep it on the server side, never in code that runs in the browser. Send it on every call as `Authorization: Bearer <key>`. Call this first, with that key, before any other step. On a missing key or a 401, stop and show a clear error that says how to fix it, such as "Trayo API key missing or invalid: create one on the API keys page in Trayo and set TRAYO_API_KEY". Never carry on and render zero results: a count of zero reads as "nothing found". Show `workspace.name` in the app so the user sees which workspace it acts in. If an MCP connection is also in use, compare `workspace.id` with the one `trayo_whoami` returns; if they differ, the key and the connection point at different workspaces, so stop and ask the user which workspace is meant. Read `canInitiate`, and `capabilities.emailLookup.available` before you offer emails.
2. `GET /v1/signals` (`listSignals`) — Pick the signal keys this app uses and show their names in the app. If none fits, create one with `createSignal` once, then reuse its key on every run.
3. `POST /v1/find` (`find`) — Filters only, so the search is free and pages: `{"target":"companies","filters":{"hq":{"countries":["US"]},"headcount":{"min":1000},"excludeWorkspace":true},"limit":100}`. For this repeatable batch, pair `hq.countries` with the indexed `headcount` band: country alone checks only a bounded set of the largest companies (`led_by_headcount`). `excludeWorkspace: true` leaves out companies the workspace already holds, so every row is new. A page can be shorter than `limit` with `hasMore: true`; follow `nextCursor` until you hold the batch. The batch is companies CHECKED, not companies that fire: a signal such as an acquisition shows up at a small share of companies, and every company checked stays in the workspace. So cap the companies checked per run and report how many fired; do not loop until a number of companies fire. A `query` search returns one page with no cursor, so it cannot fill a batch of new companies run after run. If `truncated` is true, narrow the filters. Read `interpreted`, `degraded` and `notes` as in `prospect-to-list`.
4. `POST /v1/accounts/batch` (`batchCreateAccounts`) — `{"accounts":[{"name","url","linkedinHandle"}]}`, up to 200: `name` and `url` off the row, `linkedinHandle` as the last path segment of `linkedinUrl` when present. Do not post the find row as is; unknown keys are rejected. Account ids are `created[].id`; a `skipped` row with `reasonCode: "duplicate"` carries the existing id in `existingId`.
5. `POST /v1/discoveries` (`startDiscovery`) — `{"accountIds":[...],"signalKeys":[...],"lookbackDays":90,"people":"none"}`. `people: "none"` because this recipe picks the people itself. Up to 500 accounts and 50 signals per run. Send an `Idempotency-Key` derived from a stable identifier for this intended execution and the sorted account ids. Reuse it for HTTP retries, but use a different key for the next scheduled execution, so a double click or a retried script never starts a second run; the same key with a different body is `409 idempotency_key_reused`. Returns 202 with the run `id`.
6. `GET /v1/discoveries/{runId}` (`getDiscovery`) *(poll)* — `settledAt` is the only sign the run is final; do not stop on `status` or `completedAt`. Then read `error` and `blockedSignals`: each entry names a signal and the `accountIds` it could not search. Accounts added minutes ago are often `account_not_ready`; start a second run on those ids once this one settles, with a new `Idempotency-Key`. `company_not_found` needs the company's identity fixed first (`updateAccount` with `linkedinHandle`). Show what is still blocked instead of folding it into a zero. A run takes minutes: an app shows progress, a script waits.
   Until: `settledAt` is non-null. Poll every few seconds with backoff and a finite cap; never a tight loop.
7. `GET /v1/events` (`listEvents`) — `?discoveryRunId=<id>&since=<the window start>`. The run page also carries events earlier runs wrote, and `since` filters on `eventDate`, so it keeps the window you promised. Follow `nextCursor` until `hasMore` is false. Group by `accountId`; `signalKeys` names the signal each event matched. A `job_change` is dated by the month the job started, so one can fall just before `since`; drop `since` to keep every event the run wrote. An empty page on a settled run with no `error` is a real "no signals in this window": say exactly that.
8. `POST /v1/stakeholders/search` (`searchStakeholders`) — One call per account that fired: `{"company":{"accountId":"..."},"limit":3}`. Omit `definition` to use the workspace's `stakeholderCriteria` (set it first, as in `start-here`). Synchronous and read-only. Run a few at a time within your rate limit.
9. `POST /v1/people/batch` (`batchCreatePeople`) — Project each stakeholder to `{fullName, title, linkedinUrl, accountId}`; the stakeholder row carries extra keys and is rejected as is. Leave out `title` or `linkedinUrl` when it is null: a null is rejected and the row lands in `skipped`. Up to 200 per call. Person ids are `created[].id`, `updated[].id`, or `existingId` on a `skipped` row.
10. `POST /v1/people/enrich/batch` (`batchEnrichPeople`) — `{"personIds":[...],"fields":["email"]}`, up to 200. Answers 200: `accepted[]` says what started per person, and `skipped` and `stoppedBy` say who was not reached and why. Show that in the app instead of an empty email cell. An email already on file costs nothing.
11. `GET /v1/people/{id}/enrich` (`getPersonContact`) *(poll)* — Per person. `email.value` is set once `email.lookup.state` is `found`. `not_found` ends this poll and can be tried again after `retryAvailableAt`; `error` can be tried again now.
   Until: No `email.lookup.state` is `in_progress`. Poll every few seconds with backoff and a finite cap; never a tight loop.

**Then:** Show one row per person: company, the event behind it (`title`, `eventDate`, `url`), name, title, email. The next run needs no bookkeeping: these companies are now in the workspace, so `excludeWorkspace` leaves them out. Put the people on a list (`prospect-to-list`) if someone else will act on them.

### Map the buying committee at one company (`buying-committee-for-a-company`)

**When:** Before you write any code, ask the user every question in `ask` and wait for the answers. If you cannot ask, start your reply with your guess for each one, then build. You are building an app where someone types a company and gets the people who would buy, champion or evaluate what the workspace sells, each with a reason, and optionally their emails. Works for any company, whether the workspace holds it or not.

**Spends:** Nothing to research or search. One lookup per email started, found or not, against `lookups.email` on `GET /v1/whoami`.

**Ask first, before any code** (wait for the answers, or state your guess for each):

- Trigger: does the user start each check, or will a scheduler they control call the API? Trayo does not schedule discovery for API-only workspaces. Keep the API key on the backend that runs the job.
- Destination: where do results go: the app itself, a spreadsheet, or a CRM such as HubSpot?
- Batch size: how many companies to CHECK per run? Default to about 50 per run, not a whole market in one call. Only some of them will show the signal, so say so up front rather than promising a number of hits.
- Where it is built: build it where you can keep editing and re-running it. Do not hand it off to a hosted app builder unless the user asks; iterating there loses the loop with this API and these recipes.
- Brand: use the user's logo and color palette by default. Ask for them, or read them off their website.

1. `GET /v1/whoami` (`whoami`) — The app needs a workspace API key. The user creates their own on the API keys page of the Trayo web app (any workspace member can, and the key gets at most their own permissions), or reuses the key their MCP connection uses. Never ask the user to paste the key into the conversation: have them put it in a secret file or environment variable the app reads, such as `TRAYO_API_KEY` in `.env`, and keep it on the server side, never in code that runs in the browser. Send it on every call as `Authorization: Bearer <key>`. Call this first, with that key, before any other step. On a missing key or a 401, stop and show a clear error that says how to fix it, such as "Trayo API key missing or invalid: create one on the API keys page in Trayo and set TRAYO_API_KEY". Never carry on and render zero results: a count of zero reads as "nothing found". Show `workspace.name` in the app so the user sees which workspace it acts in. If an MCP connection is also in use, compare `workspace.id` with the one `trayo_whoami` returns; if they differ, the key and the connection point at different workspaces, so stop and ask the user which workspace is meant. Read `canInitiate`, and `capabilities.emailLookup.available` before you offer emails.
2. `GET /v1/workspace` (`getWorkspace`) — Read `stakeholderPersonas` (`title`, `seniority`, `buyingRole`) and `stakeholderCriteria`: together they are the committee, so use the personas as the groups the app shows. If both are empty, ask the user to set them with `updateWorkspace`; without them the search ranks on seniority alone.
3. `POST /v1/research/company` (`researchCompany`) *(optional)* — `{"company":{"website":"acme.com"}}`. Shows who the company is (`displayName`, `oneLiner`, `employeeCount`) so the user can confirm it is the one they meant. A few seconds.
4. `POST /v1/stakeholders/search` (`searchStakeholders`) — `{"company":{"website":"acme.com"},"limit":10}`, or `{"company":{"accountId":"..."}}` for an account you hold. Omit `definition` to search with the workspace's criteria, or send one persona as `definition` per call to fill each role separately. Check the returned `company` before you trust the people. Show each person's `whyThisPerson`. Nothing is saved.
5. `POST /v1/accounts/batch` (`batchCreateAccounts`) *(optional)* — Only when the user keeps people, the returned `company.accountId` is null, and you want the account id first. Send `{name, url}` from the resolved company; a duplicate returns `existingId`. Otherwise skip this step and send `company: {name, domain}` on each person below.
6. `POST /v1/people/batch` (`batchCreatePeople`) *(optional)* — The people the user keeps, projected to `{fullName, title, linkedinUrl}` plus `accountId` or `company: {name, domain}`. Leave out `title` or `linkedinUrl` when it is null.
7. `POST /v1/people/enrich/batch` (`batchEnrichPeople`) *(optional)* — Emails, as in `signal-triggered-prospecting`: `{"personIds":[...],"fields":["email"]}`.
8. `GET /v1/people/{id}/enrich` (`getPersonContact`) *(poll, optional)* — Per person, until the email lookup is no longer `in_progress`.
   Until: No `email.lookup.state` is `in_progress`. Poll every few seconds with backoff and a finite cap; never a tight loop.

**Then:** Nothing is saved before the user keeps someone, so the app can map as many companies as it likes and write only the committees worth working.

## Endpoints

## Accounts

Companies you track. Discovery runs against accounts, and each one carries `scores` — Trayo's ranking of how much is happening there.

### POST /v1/accounts/lookalikes

`findLookalikeCompanies` — Find companies similar to a list of seed companies

**Required permissions:** `accounts:read`.

Send 1–50 companies by companyId, linkedinUrl, website or name, and a limit (default 100, maximum 500). companyId is an exact company dataset ID (number or numeric string), not a workspace account UUID. When supplied, it takes precedence; a missing ID is reported without trying other fields. Otherwise each company resolves by LinkedIn first, then its marketing website including redirects, then its name. Duplicate matched identities contribute once. No saved accounts are required. Optional hard filters minEmployees, maxEmployees, countries (ISO-2 codes or country names), regions and industries drop candidates outside them, including those with an unknown value for a filtered field unless includeUnknown is set; an employee bound replaces the seed size range.

Returns candidates sorted by similarity to the closest seed, with preliminary high/medium/low bands. Matched seeds and candidates include companyId for reuse in later searches. Check seeds for each matched identity and whether it contributed. Unmatched or unindexed seeds are reported and skipped. If none can contribute, companies is empty. A search failure returns 503. The seed companies themselves are excluded; other existing workspace accounts may appear.

Synchronous, read-only and unmetered. Allow more than 120 seconds for the client timeout. Returns up to limit candidates in one response, with no pagination. Import selected results using POST /v1/accounts/batch.

**Request body** (`application/json`)

- `companies` (object[], required) — Seed companies. Use companyId as an exact reference when supplied. Otherwise, try LinkedIn first, then its marketing website (including redirects), then its name. Stop at the first match.
  - `companyId` (integer | string, optional) — Exact company dataset ID, as a number or numeric string. Reuse companyId from a lookalike result or id from a known company find result. This is not a workspace account UUID. When supplied, it takes precedence; a missing ID is reported as not_found without trying other fields.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.
- `limit` (integer, optional) — Maximum number of candidates to return. Fewer may be available; there is no pagination.
- `matchSize` (boolean, optional) — Keep results within the employee range of the seeds. On by default: when the seeds are your accounts or target accounts, size is part of a good match. Set false to find companies that do what the seeds do at any size, for example when the seeds are competitors in one niche.
- `useWorkspaceAccounts` (boolean, optional) — Rank results partly by where people at your workspace's accounts used to work: among companies similar to the seeds, the ones your accounts hire from rank higher. On by default. Set false to rank on the seeds alone.
- `minEmployees` (integer, optional) — Hard filter: keep only companies with at least this many employees. A company with an unknown employee count is left out unless includeUnknown is set. Either employee bound replaces the seed size range.
- `maxEmployees` (integer, optional) — Hard filter: keep only companies with at most this many employees. A company with an unknown employee count is left out unless includeUnknown is set. Either employee bound replaces the seed size range.
- `countries` (string[], optional) — Hard filter: the headquarters country, as ISO-2 codes (`["US", "DE"]`) or country names (`["United States", "Germany"]`), any case. A name is read as its ISO-2 code. A company with an unknown country is left out unless includeUnknown is set. Merged with regions.
- `regions` (one of: "north_america" | "europe" | "asia_pacific" | "latin_america" | "middle_east" | "africa"[], optional) — Hard filter: shorthand for a block of countries (north_america, europe, asia_pacific, latin_america, middle_east, africa). Merged with countries.
- `industries` (string[], optional) — Hard filter: exact industry values, the same taxonomy as find (list them with trayo_list_industries or GET /v1/find/industries), case-insensitive. Several values are a union. An unrecognised value is a 400 that names it.
- `includeUnknown` (boolean, optional) — Keep companies whose value for a filtered field is unknown instead of leaving them out. About 9.5% of companies have no known headquarters country and 3.5% no industry; employee counts are nearly always known. Kept rows show the unknown field as null.

**Responses**

- `200` → LookalikeSearchResponseDto: 
  - `seeds` (object[], required) — One entry per input, in the same order. Check the matched identity, especially for a name-only input.
    - `input` (object, required)
    - `company` (object | null, required)
    - `status` (one of: "resolved" | "not_found" | "not_indexed", required) — resolved: contributed to the search. not_found: no company matched the input. not_indexed: the company matched but similarity data is unavailable for it.
  - `companies` (object[], required) — Candidates ranked best match first: a company close to several seeds ranks above one close to a single seed. Seed companies, their subsidiaries and sister brands, and repeat listings of one company are left out; see filtered. Other companies already in your workspace may appear. Nothing is saved.
    - `companyId` (integer, required) — Company dataset ID. Reuse as companyId in a subsequent search.
    - `name` (string | null, required)
    - `website` (string | null, required)
    - `linkedinUrl` (string | null, required)
    - `industry` (string | null, required)
    - `sizeRange` (string | null, required)
    - `sizeEmployees` (number | null, required)
    - `country` (string | null, required)
    - `city` (string | null, required)
    - `similarity` (one of: "high" | "medium" | "low", required) — Heuristic bands: high >= 0.85, medium >= 0.7, low below that. These are preliminary similarity bands, not probabilities.
    - `similarityScore` (number, required) — Similarity to the closest seed, from 0 to 1. Higher is closer.
    - `closestSeedIndex` (integer, required) — Zero-based index into seeds for the closest contributing input. Duplicate identities use the first input.
    - `seedsMatched` (integer, required) — Number of distinct seed companies that retrieved this candidate.
  - `filtered` (object, required) — What was left out so that only strong matches remain.
    - `seedAffiliates` (integer, required) — Candidates left out as a subsidiary, sister brand or another listing of a seed company.
    - `duplicates` (integer, required) — Candidates left out as another listing of a company already in the results.
    - `outsideSeedSize` (integer, required) — Candidates left out because their employee count is outside seedSizeRange.
    - `outsideFilters` (integer, required) — Candidates retrieved and then left out by minEmployees, maxEmployees, countries, regions or industries. Most filtering happens inside the search, and companies it never retrieved are not counted, so this is usually 0 and is not the number of companies the filters excluded.
    - `seedSizeRange` (object | null, required) — The employee range results were kept within. Null when matchSize is false or fewer than three seeds have a known employee count.
  - `rankedByYourAccounts` (object | null, required) — Null when useWorkspaceAccounts is false, your workspace has no matched accounts, or this ranking is unavailable for the request.
    - `companies` (integer, required) — How many of your workspace's account companies were used to rank the results.
    - `boosted` (integer, required) — Results ranked higher because at least two people at those companies used to work there.
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `lookalike_search_not_configured` (`retry: do_not_retry`) — Lookalike search is unavailable. Retrying will not help. Contact support.

`lookalike_search_failed` (`retry: retry_later`) — Lookalike search failed or exceeded its time budget. Nothing was saved; retry the same request shortly.

`company_resolution_timed_out` (`retry: retry_later`) — Resolving the company by name timed out under load before we could answer. This is NOT a "we do not have it" result: nothing was recorded, and the same request sent again shortly will usually resolve. Companies sent by website or LinkedIn page do not take this path.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/accounts/lookalikes" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"companies":[{"companyId":1}]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/accounts/lookalikes", headers=HEADERS, json={"companies":[{"companyId":1}]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/companies/competitors

`generateCompetitorRoster` — Rank the competitors of one company

**Required permissions:** `accounts:read`.

Send one company ({ self: true } for your own workspace, or companyId / linkedinUrl / website / name) and get back a ranked roster, default 15 and at most 50. Rank 1 is your first pin when you send pins, else the category incumbent, else the closest match on product and buyer. Candidates are drawn from companies similar to the subject plus companies named while the roster is generated; each is judged direct, adjacent or not a competitor, with 0..100 product and ICP alignment.

The roster is stored per workspace and subject. A repeat call with the same pins and an equal or smaller cap returns the stored roster (reused: true) and does not generate a new one, so a changed positioning has no effect until you send refresh: true. Sending pins replaces the stored pins for that subject; a new roster is generated when the pin set changes. Sending pins stores them for the workspace under the same accounts:read scope. Synchronous. Generation typically takes 10 to 25 seconds. Allow more than 120 seconds for the client timeout.

**Request body** (`application/json`)

- `company` (object | object, required) — The subject whose competitors you want.
  - `self` (boolean, required) — The calling workspace's own company, resolved from its website.
- `pins` (object[], optional) — Competitors you already know, most important first; the first is rank 1. Sending this REPLACES the stored pins: omit it to keep them, send [] to clear them, and to add a competitor send the current pins with it. Pins are also used as extra seeds, so changing them regenerates the roster.
  - `companyId` (integer | string, optional) — Exact company dataset ID, as a number or numeric string. Reuse companyId from a lookalike result or id from a known company find result. This is not a workspace account UUID. When supplied, it takes precedence; a missing ID is reported as not_found without trying other fields.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.
- `cap` (integer, optional) — Maximum roster length. Default 15, maximum 50.
- `positioning` (string, optional) — What the subject sells and to whom. Defaults to the workspace's own positioning for { self: true }, else the company's public description. It is read only while a roster is being generated: a repeat call with the same pins and an equal or smaller cap returns the stored roster and ignores a changed positioning, so send refresh: true to apply one.
- `refresh` (boolean, optional) — Regenerate even when a stored roster with the same pins is available.

**Responses**

- `200` → CompetitorRosterResponseDto: 
  - `subject` (object, required)
    - `companyId` (integer, required)
    - `name` (string, required)
    - `website` (string | null, required)
    - `linkedinUrl` (string | null, required)
    - `resolvedBy` (string, required) — Which key identified the subject: linkedin, website, redirect, name, or company_id for an exact lookup.
    - `indexed` (boolean, required) — false when similarity data is unavailable for the subject; the roster then comes from pins and nominations only.
  - `pins` (object[], required) — One entry per stored pin, in rank order.
    - `input` (object, required)
    - `company` (object | null, required)
    - `status` (one of: "resolved" | "not_found", required)
    - `seeded` (boolean, required) — true when this pin seeded the similarity search in the run that produced this response; false on a reused roster.
  - `entries` (object[], required) — The roster, rank 1 first.
    - `rank` (integer, required)
    - `companyId` (integer, required)
    - `name` (string | null, required)
    - `website` (string | null, required)
    - `linkedinUrl` (string | null, required)
    - `industry` (string | null, required)
    - `sizeRange` (string | null, required)
    - `verdict` (one of: "direct" | "adjacent" | "not_competitor", required)
    - `productAlignment` (integer, required)
    - `icpAlignment` (integer, required)
    - `reason` (string, required)
    - `source` (one of: "pool" | "pin" | "nomination", required) — pool: retrieved from similarity data. pin: you named it. nomination: named while the roster was generated, then matched to a company.
    - `rankReason` (one of: "pinned" | "incumbent" | "alignment", required)
    - `similarity` (number | null, required)
    - `seedsMatched` (integer | null, required)
    - `customers` (object, required) — The customers this competitor shows on its own website.
  - `unresolvedNominations` (object[], required) — Companies named while the roster was generated that matched nothing in our data. Not in the roster.
    - `name` (string, required)
    - `website` (string | null, required)
  - `cap` (integer, required)
  - `generatedAt` (string, required) — ISO 8601.
  - `reused` (boolean, required) — true when the stored roster was returned without generating a new one.
  - `stats` (object, required)
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: `company_not_found` (`retry: do_not_retry`) — We could not identify this company — not from the account's own record, nor from a LinkedIn page, website (including its redirect), or name — so there are no people to search. Retrying will not help. Send the company's own website if you did not; for any company, `POST /v1/find` on the contacts lane searches the web instead. From `POST /v1/find` with `filters.companyWebsites`, this means NO website resolved and nothing was searched; the message names every website. For one that is ambiguous (several possible companies on the domain) it also names the candidates and their ids: send the right id as `filters.companyIds`. When only some websites fail, the search runs over the rest and lists the others in `unresolvedWebsites`.
- `429` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `competitor_roster_not_configured` (`retry: do_not_retry`) — Competitor rosters are unavailable in this environment. Retrying will not help. Contact support.

`competitor_roster_failed` (`retry: retry_later`) — Competitor roster generation failed or exceeded its 45 second budget. Nothing was saved; the previous roster, if any, is unchanged. Retry the same request shortly.

`company_resolution_timed_out` (`retry: retry_later`) — Resolving the company by name timed out under load before we could answer. This is NOT a "we do not have it" result: nothing was recorded, and the same request sent again shortly will usually resolve. Companies sent by website or LinkedIn page do not take this path.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/companies/competitors" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"company":{"self":true}}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/companies/competitors", headers=HEADERS, json={"company":{"self":True}})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/accounts

`listAccounts` — List accounts

**Required permissions:** `accounts:read`.

A page of accounts, most recently created first. PAGINATES: follow `nextCursor` until `hasMore` is false.

**Parameters**

- `q` (query, string, optional) — Case-insensitive substring match against `name` only — does not search `url` or anything else. This is how you look up whether an account already exists, and find its `id` to pass as `accountId` on `POST /v1/people`.
- `updatedSince` (query, string, optional) — Send a plain date — `2026-06-07`, read as midnight UTC — or a full ISO-8601 timestamp — `2026-06-07T14:30:00Z` — when you need the time of day. **Relative words are not accepted:** `yesterday`, `last-quarter`, `7d` and anything else that has to be resolved against "now" is rejected with a 400 (`invalid_date`). Compute the date yourself and send it. Returns accounts with `updatedAt >=` this value.
- `cursor` (query, string, optional) — Opaque pagination cursor from a previous page's `nextCursor`. Omit for the first page. Do not construct or parse this value — it is a base64url-encoded implementation detail and may change shape without notice.
- `limit` (query, integer, optional) — Page size, default 25. Values above 100 are silently CAPPED to 100, not rejected. Note the deliberate contrast with the two other bounds in this API: `POST /v1/find`'s `limit` is rejected above 100 (and a sentence search returns at most 50), and a `/v1/…/batch` array of more than 200 items is an ERROR rather than a clamp — silently dropping rows would lose records the caller believes it created. A `limit` that is zero, negative or not an integer is rejected here too; only the upper bound clamps.

**Responses**

- `200` → AccountListDto: 
  - `data` (object[], required) — Accounts, most recently created first.
    - `id` (uuid, required)
    - `name` (string, required)
    - `url` (uri | null, 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 | null, 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 | null, 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`.
    - `pointOfView` (object | null, 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.
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
  - `hasMore` (boolean, required) — Another page exists. When `false` you have reached the end — stop requesting pages.
  - `nextCursor` (string | null, required) — Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.

`invalid_date` (`retry: fix_input`) — A date filter (`updatedSince`, `since`, `detectedSince`) did not parse. Send a plain date (`2026-06-07`, read as midnight UTC) or a full ISO-8601 timestamp (`2026-06-07T14:30:00Z`). Relative words such as `yesterday` or `last-quarter` are not accepted. `param` names the field.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/accounts" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/accounts", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/accounts

`createAccount` — Create an account

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

An account is a name, an optional URL and an optional LinkedIn handle. That is the whole record — see `AccountDto` for what reads back. `name` is the only required field: an account you have no website for is created with `url: null` rather than refused, the same way `POST /v1/people` treats a `company` with no `domain`. Send `url` whenever you have it — it is this route's deduplication key. Supply `linkedinHandle` whenever you know the company page: use the bare handle (for example `stripe`), not the full LinkedIn URL. It is the strongest way to identify the company and takes precedence over the website and name.

You can start discovery immediately after creating an account. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.

With usage enforcement on: **Adding an account draws on your workspace quota** — once for the month it is added, and again for each month it stays in the workspace. A workspace whose quota cannot cover the new account is refused with `402 quota_exceeded`, and nothing is created.

 With it off, usage has no quotas. **This call adds the account only, never people.** Finding people and adding them are separate steps: search with `POST /v1/find` as many times as you need, then add the people you chose with `POST /v1/people/batch`.

To attach people to future events, first set the workspace stakeholder definition with `PATCH /v1/workspace`, then start discovery with `people: "add"`. This definition does not add people to older events.

**A `POST /v1/find` company row is NOT postable verbatim.** Its `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `foundedYear`, `companyType`, `description`, `why`, `known`, `state` fields are all refused with `validation_failed`, and the message names the offending keys. They are refused rather than accepted-and-dropped on purpose: these company details cannot be saved through this route. Select the accepted fields first: `jq '.data[] | select(.state == "selectable" and .url) | {name, url}'`.

**Request body** (`application/json`)

- `name` (string, required) — The company's display name. Matching falls back to this only after `linkedinHandle` and `url`, so supply the stronger identifiers whenever you have them. It is also this account's LAST deduplication key on `POST /v1/accounts/batch`, and it counts only against a stored account that has neither a usable website nor a handle — two companies that share a name but each have a website are both created.
- `url` (uri | null, optional) — The company website. Must include a scheme when given, e.g. `https://stripe.com` — `stripe.com` alone is rejected. The hostname is also this account's deduplication key on `POST /v1/accounts/batch`. A row WITHOUT a url is deduplicated by NAME instead (case-insensitive, within your workspace), so it is not created twice — but a url is the stronger key, because two different companies can share a name. After `linkedinHandle`, this is the next way to identify the company: send it whenever you have it. OPTIONAL since TRA-2077. An account you only have a NAME for is created with no website (`url: null` on reads) rather than refused — the same thing `POST /v1/people` does with a `company` that carries no `domain`, and the same thing Trayo's own importer has always done. Two enforcement levels for one policy was the bug: this route refused rows the people route created.
- `linkedinHandle` (string, optional) — `linkedinHandle` is the bare LinkedIn company handle, e.g. `stripe` from `https://www.linkedin.com/company/stripe/`. Supply it whenever known: it is the strongest way to identify the company and is tried before `url` and `name`. It is also this account's SECOND deduplication key on `POST /v1/accounts/batch`, checked when the hostname does not match, so a row whose website is new to this workspace can still be skipped as a duplicate on its handle alone.

**Responses**

- `201` → AccountDto: Account created.
  - `id` (uuid, required)
  - `name` (string, required)
  - `url` (uri | null, 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 | null, 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 | null, 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 | null, required) — Null until Trayo has computed it. `calculatedAt` records when this score was last updated.
    - `activity` (object | null, required) — Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it.
  - `pointOfView` (object | null, 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.
    - `whyNow` (object[], required) — At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for.
    - `generatedAt` (date-time, required) — When this point of view was written.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: Malformed body — e.g. a missing `name`, a `url` without a scheme, or an unknown field. Posting a `POST /v1/find` accounts row unprojected is the common case: `param` names the first extra key.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/accounts" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"<name>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/accounts", headers=HEADERS, json={"name":"<name>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/accounts/batch

`batchCreateAccounts` — Batch create accounts

**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`, `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`.

With usage enforcement on: **Each account the batch creates draws on your workspace quota**, as on `POST /v1/accounts`; a duplicate or malformed row draws nothing. When the quota cannot cover every account the batch would create, the whole call is refused with `402 quota_exceeded` and NOTHING is created — send fewer rows, or raise the quota.

 With it off, usage has no quotas. **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** (`application/json`)

- `accounts` (string[], 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.

**Responses**

- `200` → BatchCreateAccountsResultDto: The request was processed. Check `skipped` — a `200` does not mean every row was created; it means the call itself succeeded.
  - `created` (object[], required) — The rows that were created, in submission order.
    - `id` (uuid, required)
    - `name` (string, required)
    - `url` (uri | null, 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 | null, 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 | null, 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`.
    - `pointOfView` (object | null, 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.
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
    - `identity` (one of: "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 | null, required) — Only for `identity: "resolved"`: the company the website belongs to. Null otherwise.
    - `alternatives` (object[] | null, required) — Only for `identity: "uncertain"`: the companies this website could name, at most 3. Null otherwise.
  - `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` (one of: "duplicate" | "identity_conflict", optional) — 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` (uuid, optional) — 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.
- `400` → ErrorDto: 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` → ErrorDto: 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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/accounts/batch" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"accounts":["<accounts>"]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/accounts/batch", headers=HEADERS, json={"accounts":["<accounts>"]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/accounts/{id}

`getAccount` — Get an account

**Required permissions:** `accounts:read`.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → AccountDto: 
  - `id` (uuid, required)
  - `name` (string, required)
  - `url` (uri | null, 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 | null, 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 | null, 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 | null, required) — Null until Trayo has computed it. `calculatedAt` records when this score was last updated.
    - `activity` (object | null, required) — Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it.
  - `pointOfView` (object | null, 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.
    - `whyNow` (object[], required) — At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for.
    - `generatedAt` (date-time, required) — When this point of view was written.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: No such account, OR it belongs to another workspace — the two are indistinguishable on purpose.

`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl "$TRAYO_API_URL/v1/accounts/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.get(f"{BASE}/v1/accounts/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### PATCH /v1/accounts/{id}

`updateAccount` — Update an account

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

**Parameters**

- `id` (path, uuid, required)

**Request body** (`application/json`)

- `name` (string, optional)
- `url` (uri, optional)
- `linkedinHandle` (string, optional)
- `starred` (boolean, optional)

**Responses**

- `200` → AccountDto: The account after the update.
  - `id` (uuid, required)
  - `name` (string, required)
  - `url` (uri | null, 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 | null, 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 | null, 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 | null, required) — Null until Trayo has computed it. `calculatedAt` records when this score was last updated.
    - `activity` (object | null, required) — Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it.
  - `pointOfView` (object | null, 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.
    - `whyNow` (object[], required) — At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for.
    - `generatedAt` (date-time, required) — When this point of view was written.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: Malformed body.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X PATCH "$TRAYO_API_URL/v1/accounts/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"<name>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.patch(f"{BASE}/v1/accounts/{id}", headers=HEADERS, json={"name":"<name>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### DELETE /v1/accounts/{id}

`deleteAccount` — Delete an account

**Required permissions:** `accounts:delete`.

Soft delete. Returns 200, not 204, with the account as it was — nothing in the response body distinguishes it from a normal GET, and there is no `deletedAt` field in this API's Account shape. The proof of deletion is behavioral: this id now 404s (`account_not_found`) on the account routes, is rejected as a `POST /v1/discoveries` `accountIds` entry with a 400 (`unknown_account`), and no longer appears in `GET /v1/accounts`. Only the account record goes: the events and people already discovered for it stay, so `GET /v1/events` and `GET /v1/people` keep returning rows carrying the deleted `accountId`.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → AccountDto: The account as it existed immediately before deletion.
  - `id` (uuid, required)
  - `name` (string, required)
  - `url` (uri | null, 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 | null, 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 | null, 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 | null, required) — Null until Trayo has computed it. `calculatedAt` records when this score was last updated.
    - `activity` (object | null, required) — Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it.
  - `pointOfView` (object | null, 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.
    - `whyNow` (object[], required) — At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for.
    - `generatedAt` (date-time, required) — When this point of view was written.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X DELETE "$TRAYO_API_URL/v1/accounts/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.delete(f"{BASE}/v1/accounts/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Signals

Named definitions of what counts as a noteworthy event. A discovery run scans for events matching your ready signals.

### GET /v1/signals

`listSignals` — List signals

**Required permissions:** `events:read`.

Returns the complete current set of your workspace's account-axis signals in a SINGLE response — every signal you have authored.

**This route does NOT paginate**, unlike `GET /v1/accounts`, `GET /v1/people`, `GET /v1/events` and `GET /v1/discoveries`. It accepts no `cursor` or `limit` (any sent are silently ignored), and the response always has `hasMore: false` and `nextCursor: null` regardless of how many signals exist. A pagination loop written against it will never advance. `POST /v1/find` is the other non-paginating route.

**Responses**

- `200` → SignalListDto: All current signals.
  - `data` (object[], required) — Every current account-axis signal, newest first.
    - `signalKey` (string, required) — The stable public identifier. There is no separate row id.
    - `type` (string | null, required)
    - `entityType` (string, required)
    - `detects` (string, required)
    - `salesIntent` (string | null, required)
    - `includes` (string[], required)
    - `excludes` (string[], required)
    - `weight` (number, required)
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
  - `hasMore` (boolean, required) — ALWAYS `false`. This route does not paginate.
  - `nextCursor` (null, required) — ALWAYS `null`. This route does not paginate.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/signals" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/signals", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/signals

`createSignal` — Create a signal

**Required permissions:** `settings:write`.

Create a signal that describes what you want to detect. Send the returned key to `POST /v1/discoveries` immediately. After the run settles, read its `error` and `blockedSignals` before reporting results.

**Request body** (`application/json`)

- `signalKey` (string, required) — Stable public identifier, lowercase alphanumeric with underscores or hyphens (`champion_move`, `funding-round`), 3-60 characters, starting and ending with a letter or digit. This is the signal's identity — it appears in the path of every other signal route and cannot be renamed. A key that is already taken is a 409 (`signal_key_conflict`); a reserved product-managed key is a 400.
- `type` (one of: "news" | "jobs" | "job_change", required) — What this signal searches. `news` searches the open web. `jobs` finds hiring activity in job postings up to the last 180 days: a longer lookback does not reach older postings, so report a jobs signal's period as at most 180 days. `job_change` finds people who started a new role. While the run is active, keep polling it. After it settles, read `blockedSignals` and `error` before reporting an empty result. `technology` is system-managed and not authorable. `reddit` is not authorable here either: those signals require a competitor list and this route has no field for one.
- `detects` (string, required) — Describe what counts as a match in plain language.
- `salesIntent` (string, optional) — Why a match matters commercially.
- `includes` (string[], optional) — Examples of what counts. For `job_change`, list every title and scope you would accept: a move matches a role the definition describes, or the company-wide chief who directly owns it (a Chief Accounting Officer for Corporate Controller), never another neighbouring title. A company-wide title such as CFO does not match a regional or divisional one (CFO, Latin America) unless you list that scope. Naming senior titles too (Chief Supply Chain Officer beside VP Logistics) makes the search look for them.
- `excludes` (string[], optional) — Cases that never count: each one vetoes a match, except one that names another signal ("this belongs to X"), which sends the match to that signal instead.
- `weight` (integer, optional) — Relative importance, 1-3. Default 1.

**Responses**

- `201` → SignalDto: Signal created, and usable immediately: name its key in `POST /v1/discoveries` as soon as this answers.
  - `signalKey` (string, required) — The stable public identifier. There is no separate row id.
  - `type` (string | null, required)
  - `entityType` (string, required)
  - `detects` (string, required)
  - `salesIntent` (string | null, required)
  - `includes` (string[], required)
  - `excludes` (string[], required)
  - `weight` (number, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: Malformed body; `signalKey` does not match the required pattern; `signalKey` is one of the reserved product-managed keys (the message names it); or `type` is not one of the four authorable values.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `signal_key_conflict` (`retry: fix_input`) — A signal with this `signalKey` already exists with a different definition. Choose another key, `PATCH` the existing one, or delete it first. Repeating a create with the same definition is not a conflict: it returns the saved signal.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/signals" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"signalKey":"<signalKey>","type":"news","detects":"<detects>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/signals", headers=HEADERS, json={"signalKey":"<signalKey>","type":"news","detects":"<detects>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/signals/{signalKey}

`getSignal` — Get a signal

**Required permissions:** `events:read`.

One signal as it stands. Nothing needs this call before a run — `POST /v1/discoveries` takes any key that exists. It is here for the workspace-inspection case.

**Parameters**

- `signalKey` (path, string, required)

**Responses**

- `200` → SignalDto: 
  - `signalKey` (string, required) — The stable public identifier. There is no separate row id.
  - `type` (string | null, required)
  - `entityType` (string, required)
  - `detects` (string, required)
  - `salesIntent` (string | null, required)
  - `includes` (string[], required)
  - `excludes` (string[], required)
  - `weight` (number, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: No signal with this key, or it is a reserved product-managed or person-entity signal and not addressable here.

`signal_not_found` (`retry: do_not_retry`) — No such signal key, or it is product-managed/person-entity and not addressable here.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {signalKey} with the real value.
curl "$TRAYO_API_URL/v1/signals/{signalKey}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

signal_key = "..."  # the {signalKey} in the path
r = requests.get(f"{BASE}/v1/signals/{signal_key}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### PATCH /v1/signals/{signalKey}

`updateSignal` — Update a signal

**Required permissions:** `settings:write`.

Updates create a new signal version. It takes effect on the next run that names the signal.

**Parameters**

- `signalKey` (path, string, required)

**Request body** (`application/json`)

- `detects` (string, optional)
- `salesIntent` (string, optional)
- `includes` (string[], optional) — Examples of what counts. For `job_change`, list every title and scope you would accept: a move matches a role the definition describes, or the company-wide chief who directly owns it (a Chief Accounting Officer for Corporate Controller), never another neighbouring title. A company-wide title such as CFO does not match a regional or divisional one (CFO, Latin America) unless you list that scope. Naming senior titles too (Chief Supply Chain Officer beside VP Logistics) makes the search look for them.
- `excludes` (string[], optional) — Cases that never count: each one vetoes a match, except one that names another signal ("this belongs to X"), which sends the match to that signal instead.
- `weight` (integer, optional)

**Responses**

- `200` → SignalDto: The signal after the update.
  - `signalKey` (string, required) — The stable public identifier. There is no separate row id.
  - `type` (string | null, required)
  - `entityType` (string, required)
  - `detects` (string, required)
  - `salesIntent` (string | null, required)
  - `includes` (string[], required)
  - `excludes` (string[], required)
  - `weight` (number, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: Malformed body, or the body tried to set `signalKey`/`type`, which this route does not accept.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `signal_not_found` (`retry: do_not_retry`) — No such signal key, or it is product-managed/person-entity and not addressable here.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {signalKey} with the real value.
curl -X PATCH "$TRAYO_API_URL/v1/signals/{signalKey}" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"detects":"<detects>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

signal_key = "..."  # the {signalKey} in the path
r = requests.patch(f"{BASE}/v1/signals/{signal_key}", headers=HEADERS, json={"detects":"<detects>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### DELETE /v1/signals/{signalKey}

`deleteSignal` — Delete a signal

**Required permissions:** `settings:write`.

The response shape differs from every other delete in this API — it is a confirmation, not the deleted resource. The `signalKey` becomes immediately reusable in a new `POST /v1/signals` call.

**Parameters**

- `signalKey` (path, string, required)

**Responses**

- `200` → SignalDeleteResultDto: Confirmation, not the deleted signal.
  - `deleted` (boolean, required)
  - `signalKey` (string, required)
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: `signal_not_found` (`retry: do_not_retry`) — No such signal key, or it is product-managed/person-entity and not addressable here.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {signalKey} with the real value.
curl -X DELETE "$TRAYO_API_URL/v1/signals/{signalKey}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

signal_key = "..."  # the {signalKey} in the path
r = requests.delete(f"{BASE}/v1/signals/{signal_key}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Discoveries

On-demand runs that scan accounts for signal matches and produce events. Asynchronous: start one, then poll it until `settledAt` is set.

### GET /v1/discoveries

`listDiscoveries` — List discovery runs

**Required permissions:** `events:read`.

Every run in your workspace, most recently started first. No filter by status or account — pagination only. PAGINATES: follow `nextCursor` until `hasMore` is false.

**Parameters**

- `cursor` (query, string, optional) — Opaque pagination cursor from a previous page's `nextCursor`. Omit for the first page. Do not construct or parse this value — it is a base64url-encoded implementation detail and may change shape without notice.
- `limit` (query, integer, optional) — Page size, default 25. Values above 100 are silently CAPPED to 100, not rejected. Note the deliberate contrast with the two other bounds in this API: `POST /v1/find`'s `limit` is rejected above 100 (and a sentence search returns at most 50), and a `/v1/…/batch` array of more than 200 items is an ERROR rather than a clamp — silently dropping rows would lose records the caller believes it created. A `limit` that is zero, negative or not an integer is rejected here too; only the upper bound clamps.

**Responses**

- `200` → DiscoveryRunListDto: 
  - `data` (object[], required) — Discovery runs, most recently started first.
    - `id` (uuid, required)
    - `status` (one of: "running" | "completed" | "failed", required) — `running`, `completed` or `failed`. This is the POLLING signal and nothing else: it tells you when to stop calling, not what the run achieved. Read `error` and `partial` even on a `completed` run. **`completed` means everything this run will ever write has been written.** It covers both halves of the run: the search across your accounts, and the step that turns what the search found into events. It flips at the same instant `settledAt` is set, and from that instant the candidate and event counts are frozen and `GET /v1/events?discoveryRunId=` returns the events that stand for the run's scope: everything it wrote, at any event date, plus matches an earlier run wrote. There is nothing to wait for afterwards. **`running` covers the write step too**, so a run whose search has finished still reads `running` while its events are being written. That is why `completedAt` can be set while `status` is `running`: the search is done, the run is not. Poll on `settledAt` (or on this field reaching `completed`, which is the same moment) and never on `completedAt`. A run that finds nothing reaches `completed` exactly as promptly as one that finds a hundred events.
    - `target` (object, required) — The accounts and signals requested for this run. Older runs can omit these details.
    - `lookbackDays` (number, required)
    - `maxResults` (number | null, required) — The requested source-result cap this run was started with, before filtering and deduplication. `null` means none was sent, so each source used its default. Google News account searches always use a fixed 10-result budget and ignore `maxResults`; this value is not an event count.
    - `people` (one of: "add" | "none", required) — The `people` option this run was started with: `add` or `none`. A run started before the option existed reads `add`, which is what it did.
    - `accountCount` (number, required) — How many accounts the run was asked to scan.
    - `relatedEvidence` (object, optional) — Optional credential-owned indexed evidence about a related implementation intent. Its candidates never change this run's original events, counts or settlement.
    - `candidatesScreened` (integer, required) — How many candidate items this run RETRIEVED and screened: articles, posts and profiles pulled from the sources for your accounts, before anything judged whether they match a signal. **It is not a count of events, and these candidates cannot be read as events.** `candidatesMatched` says how many of them matched a signal, `eventsNew` how many new events the run wrote, and the events themselves are read with `GET /v1/events?discoveryRunId=`. `candidatesScreened: 12` with `candidatesMatched: 0` means 12 items were screened and none matched any signal, so this execution wrote no events and there is nothing of its own to read. The same number as the deprecated `eventsFound`. **Final once `settledAt` is set**, and still moving before that. **`candidatesScreened: 0` on a RE-RUN does not mean nothing was searched.** Items a signal has already seen are dropped before they are stored, so a second identical run reports 0 even though the same sources were queried and returned the same items: on any later run this number says how much was NEW to that signal, not how much was looked at. To tell "nothing was searched" from "nothing was new", use `blockedSignals` and `error`, which do not decay on re-runs.
    - `candidatesMatched` (integer | null, required) — How many of the screened candidates matched a signal and went into an event: a new event, or an event already in your workspace that absorbed them. Several matched candidates can make one event, so this is not an event count either; `eventsNew` is. `candidatesMatched` above zero with `eventsNew: 0` means what matched was already known: the scoped events from `GET /v1/events?discoveryRunId=` hold it. The sum of `bySignal[].candidatesMatched`. Final once `settledAt` is set. Null only on a run settled before this field existed.
    - `bySignal` (object[] | null, required) — The screened and matched candidates per signal the run covered, and the accounts each fired at (`accountsWithEvents`, the fire-rate numerator), sorted by `signalKey`. Every signal is listed, including one that screened or matched nothing, so a signal that matched no account reads `candidatesMatched: 0` here rather than being absent. A signal with `candidatesScreened: 0` may not have searched at all: check `blockedSignals` and `failedChecks` for it. Empty when the run searched nothing (`error` says why). Final once `settledAt` is set. Null only on a run settled before this field existed.
    - `eventsFound` (number, required) — **Deprecated: read `candidatesScreened`, the same number under a name that says what it counts.** Kept, with the same value, for callers that already read it. It counts candidate items retrieved and screened, NOT events: `eventsFound: 12` with no events to read means 12 candidates were screened and none matched (`candidatesMatched: 0`), not 12 events that are missing.
    - `eventsNew` (number, required) — How many new events THIS RUN WROTE to your workspace. It is the run's execution receipt, not the size of `GET /v1/events?discoveryRunId=`. That list answers the customer's question instead: every live event this run wrote, whatever its event date, plus every one standing for the run's accounts, signals and lookback window, including events written by earlier runs. It can therefore be non-empty, or longer than this count, when `eventsNew` is zero — and on a settled run, read to the end, it is never shorter than this count except where an event has since been withdrawn or deleted. One page is capped at 100, so compare against everything `nextCursor` yields, never against a single page. The list can also be shorter. A safety net downstream of discovery can withdraw an event after the run ends, and a caller can delete one; the frozen receipt still says the run wrote it. Explicit `state=dismissed` reads Trayo's withdrawals in the same scope, while caller-deleted events remain absent. **Act on it once `settledAt` is set, and not before.** Until then the run is still writing events and this number is still rising: a value read from an unsettled run is a partial count, not a small result. Once `settledAt` is set the number is frozen — it is the run's receipt, so it stays what it was even if you later delete some of the events the run created. **`eventsNew: 0` on a `completed` run is a success, not a failure.** Results are deduplicated against everything already in the workspace, so RE-RUNNING AN IDENTICAL DISCOVERY IS SAFE: the same accounts, signals and lookback typically return `eventsNew: 0` on the second call and create no duplicate events. Do not treat a zero here as a reason to retry. Read the scoped events page, `error`, and `blockedSignals`; do not branch on `status` alone — a `completed` run always means the work finished, whatever it found.
    - `blockedSignals` (object[], required) — Signals that did not search some accounts. Each entry identifies the affected accounts in `accountIds` and explains the cause in `reason`. Read this field and `error` before reporting an empty result. These reasons alone do not mark the whole run as failed. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.
    - `partial` (boolean, required) — True when the results are INCOMPLETE: at least one account × signal check failed (`failedChecks` lists them), or the run was closed with work still outstanding (`error` says what). `status` stays `completed` on such a run — it says the run stopped, not that every search succeeded. Read `failedChecks` for which signals and accounts, and do not report `eventsNew: 0` on a partial run as "nothing happened". Can turn true while the run is still going; final once `settledAt` is set.
    - `failedChecks` (object, required) — Searches this run STARTED and could not finish — a data source timed out or failed. Unlike `blockedSignals`, which lists searches that could not start, these accounts were eligible and simply went unsearched for these signals. An empty result for them is not evidence that nothing happened.
    - `progress` (object, required) — How far the run has come. Equal account numbers mean no account is still being searched; the run is final only when `settledAt` is set, because events are still being written after the last account answers.
    - `retry` (one of: "retry_later" | null, required) — `retry_later` on a settled partial run whose failures were transient (a `timeout` or `source_unavailable` check, or work cut short): re-running the same accounts and signals unchanged, later, is the fix. Null otherwise — a clean run needs no retry, a run still going has not finished failing, and a run whose every failure is `internal` would fail the same way.
    - `startedAt` (date-time, required)
    - `completedAt` (date-time | null, required) — When the SEARCH finished — every account in the run reported back. **It is not the end of the run and it is not what to poll on:** the events the search found are written afterwards, so this can be set while `status` is still `running`. Wait for `settledAt`. Null until the search finishes, then stable.
    - `settledAt` (date-time | null, required) — THE FINISH LINE — when the run became final: the search finished AND every event it produced has been written. **Act when `settledAt` is set.** Poll `GET /v1/discoveries/{runId}` until this field is non-null; at that moment `status` reads `completed` or `failed`, the candidate and event counts are frozen for good, and `GET /v1/events?discoveryRunId=` returns every live event this run wrote — at any event date, since a job change is dated by the month the job STARTED and can predate the lookback — plus every one standing for the run's account × signal × lookback scope, including events an earlier run wrote. The counts remain this run's frozen receipt. Do not poll on `completedAt`, and do not try to detect the end by watching `eventsNew` stop changing — a pause between two writes looks exactly like the end of the run and is not one. Null until the run settles, then stable.
    - `error` (string | null, required) — Read this even when `status` is `completed`. A completed run with a non-null `error` ended without doing everything it was asked to — most commonly because none of the requested accounts was eligible (every account must be assigned to a user), which is the single most likely reason a first-time caller sees no new events. The scoped events page can still contain matches written before this run, so this field remains the authority on whether this execution failed.
  - `hasMore` (boolean, required) — Another page exists. When `false` you have reached the end — stop requesting pages.
  - `nextCursor` (string | null, required) — Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/discoveries" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/discoveries", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/discoveries

`startDiscovery` — Start a discovery run

**Required permissions:** `events:write`.

**ASYNCHRONOUS.** Returns 202 immediately with a run whose `status` is `running` — nothing has been searched yet. Poll `GET /v1/discoveries/{runId}` until `settledAt` is non-null, then read the events that stand for its scope with `GET /v1/events?discoveryRunId={runId}`. `settledAt` is set when the run is final — searched AND every event written — and `status` reads `completed` or `failed` at the same instant. `candidatesScreened`, `candidatesMatched` and `eventsNew` are frozen execution counters; the scoped event page holds everything this run wrote — including events dated outside the lookback, as a job change dated by its start month can be — plus matching canonical events written by earlier runs.

**Re-running an identical discovery is safe.** Results deduplicate against everything already in the workspace, so the same accounts/signals/lookback typically returns `eventsNew: 0` the second time and creates no duplicate events. That is success.

**Idempotency:** send `Idempotency-Key` on every call you might retry. Repeating the same key with the byte-identical body returns the ORIGINAL run with the same `id`, without starting another search, but with its CURRENT state, not a frozen copy: if the original has since completed, the replay shows `status: completed` and real counts. The same key with a DIFFERENT body is a 409. Keys are not scoped to a time window in this version.

**A 202 does not guarantee events will follow.** If none of the requested accounts are eligible — every account must be assigned to a user, which accounts created via `POST /v1/accounts` always are — the run still returns 202 and then immediately reads `status: completed`, `eventsNew: 0`, with `error` explaining why. This is the single most likely way a first-time caller sees "no events": check `error` before assuming discovery is still running or that your signals match nothing.

You can use new accounts and signal keys immediately. Poll the same run until `settledAt` is set.

With usage enforcement on: **Quota:** a run draws nothing itself — the accounts it covers already count against your workspace quota — but a workspace whose quota is used up is refused with `402 quota_exceeded` before anything is searched. A replayed `Idempotency-Key` is never refused this way.

 With it off, usage has no quotas. **People:** by default (`people: "none"`) a run adds no people to your workspace — its events come back with `people: []`. A `job_change` event still names the person who changed jobs; add them with `POST /v1/people` if you want them. Send `people: "add"` to have the people who matter to each event added to your workspace and listed on it.

Read `blockedSignals` before reporting an empty result. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.

**Each blocked entry NAMES the accounts** in `accountIds` (the first 200 of them; `accountsAffected` is the true total), so a re-run can be scoped to exactly those rather than everything you sent.

**Parameters**

- `Idempotency-Key` (header, string, optional) — Free-form string. Supported on THIS route only — no other `/v1` route reads it.

**Request body** (`application/json`)

- `accountIds` (uuid[], required) — Accounts to scan, 1 to 500. Every id must exist in your workspace — an unknown one is a 400 (`unknown_account`) listing the offenders, not a silent skip.
- `lookbackDays` (integer, required) — How far back to look for events, in days. `jobs` signals read job postings up to the last 180 days. A lookback longer than 180 days reads 180: report their period as at most 180 days, not the lookback.
- `maxResults` (integer, optional) — Requested source-result cap per account and signal, 1 to 1000, before filtering and deduplication; this is not a target number of matching events. Omit it to use each source's default — 25 for jobs, posts and people searches. A number raises or lowers those defaults. Google News account searches always use a fixed 10-result budget and ignore `maxResults`. Send a number or omit the field — `null` is rejected.
- `signalKeys` (string[], optional) — Omit to run every signal in the workspace. You can use a new signal key immediately. A key that does not exist is a different thing and is a 400 (`unknown_signal`). Send at least one key or omit the field — an EMPTY array is rejected, because it reads like "no signals" and used to mean "all of them". Each selected signal searches its enabled sources for every account. More accounts, signals and source results mean more work and a longer wait. Select only the signal keys relevant to the task; omitting this field can make a large run take much longer.
- `people` (one of: "add" | "none", optional) — `none` (default): this run adds no people to your workspace; its events come back with `people: []`. A `job_change` event still names the person who changed jobs; add them with POST /v1/people if you want them. `add`: the people who matter to each event are found, added to your workspace and listed on the event's `people` as the run goes. Find and add people yourself any time with POST /v1/find and POST /v1/people/batch.

**Responses**

- `202` → DiscoveryRunDto: Run accepted (or, for a replayed Idempotency-Key, the original run's current state).
  - `id` (uuid, required)
  - `status` (one of: "running" | "completed" | "failed", required) — `running`, `completed` or `failed`. This is the POLLING signal and nothing else: it tells you when to stop calling, not what the run achieved. Read `error` and `partial` even on a `completed` run. **`completed` means everything this run will ever write has been written.** It covers both halves of the run: the search across your accounts, and the step that turns what the search found into events. It flips at the same instant `settledAt` is set, and from that instant the candidate and event counts are frozen and `GET /v1/events?discoveryRunId=` returns the events that stand for the run's scope: everything it wrote, at any event date, plus matches an earlier run wrote. There is nothing to wait for afterwards. **`running` covers the write step too**, so a run whose search has finished still reads `running` while its events are being written. That is why `completedAt` can be set while `status` is `running`: the search is done, the run is not. Poll on `settledAt` (or on this field reaching `completed`, which is the same moment) and never on `completedAt`. A run that finds nothing reaches `completed` exactly as promptly as one that finds a hundred events.
  - `target` (object, required) — The accounts and signals requested for this run. Older runs can omit these details.
    - `kind` (string, optional)
    - `accountIds` (string[], optional)
    - `signalKeys` (string[] | null, optional)
  - `lookbackDays` (number, required)
  - `maxResults` (number | null, required) — The requested source-result cap this run was started with, before filtering and deduplication. `null` means none was sent, so each source used its default. Google News account searches always use a fixed 10-result budget and ignore `maxResults`; this value is not an event count.
  - `people` (one of: "add" | "none", required) — The `people` option this run was started with: `add` or `none`. A run started before the option existed reads `add`, which is what it did.
  - `accountCount` (number, required) — How many accounts the run was asked to scan.
  - `relatedEvidence` (object, optional) — Optional credential-owned indexed evidence about a related implementation intent. Its candidates never change this run's original events, counts or settlement.
    - `profile` (one of: "ap_p2p_implementation_v1" | "ap_p2p_related_jobs_v2", required)
    - `relation` (one of: "related_candidate" | "alternative_candidate", required) — Related job evidence, never original signal hits. alternative_candidate means the named original signals settled cleanly with zero accounts with events; it does not verify a replacement event.
    - `signalKeys` (string[], required)
    - `families` (object[], optional) — v2 selected families and original signal keys; item attribution is phrase-based only.
    - `status` (one of: "running" | "done" | "unavailable", required)
    - `searchId` (string | null, required) — The existing evidence search, readable only by the credential that started this run. Reading it does not repeat the search.
    - `searchedAccountIds` (string[], required) — Accounts submitted to this pass; native source coverage says what was actually read.
    - `notSearchedAccountIds` (string[], required) — Requested run accounts excluded by eligibility or the existing evidence-search company bound.
    - `sources` (object[], required)
    - `items` (object[], required)
    - `note` (string, required) — Scope and candidate limitations. A posting date, stored active status or prior experience does not establish an ongoing project or an RFP.
  - `candidatesScreened` (integer, required) — How many candidate items this run RETRIEVED and screened: articles, posts and profiles pulled from the sources for your accounts, before anything judged whether they match a signal. **It is not a count of events, and these candidates cannot be read as events.** `candidatesMatched` says how many of them matched a signal, `eventsNew` how many new events the run wrote, and the events themselves are read with `GET /v1/events?discoveryRunId=`. `candidatesScreened: 12` with `candidatesMatched: 0` means 12 items were screened and none matched any signal, so this execution wrote no events and there is nothing of its own to read. The same number as the deprecated `eventsFound`. **Final once `settledAt` is set**, and still moving before that. **`candidatesScreened: 0` on a RE-RUN does not mean nothing was searched.** Items a signal has already seen are dropped before they are stored, so a second identical run reports 0 even though the same sources were queried and returned the same items: on any later run this number says how much was NEW to that signal, not how much was looked at. To tell "nothing was searched" from "nothing was new", use `blockedSignals` and `error`, which do not decay on re-runs.
  - `candidatesMatched` (integer | null, required) — How many of the screened candidates matched a signal and went into an event: a new event, or an event already in your workspace that absorbed them. Several matched candidates can make one event, so this is not an event count either; `eventsNew` is. `candidatesMatched` above zero with `eventsNew: 0` means what matched was already known: the scoped events from `GET /v1/events?discoveryRunId=` hold it. The sum of `bySignal[].candidatesMatched`. Final once `settledAt` is set. Null only on a run settled before this field existed.
  - `bySignal` (object[] | null, required) — The screened and matched candidates per signal the run covered, and the accounts each fired at (`accountsWithEvents`, the fire-rate numerator), sorted by `signalKey`. Every signal is listed, including one that screened or matched nothing, so a signal that matched no account reads `candidatesMatched: 0` here rather than being absent. A signal with `candidatesScreened: 0` may not have searched at all: check `blockedSignals` and `failedChecks` for it. Empty when the run searched nothing (`error` says why). Final once `settledAt` is set. Null only on a run settled before this field existed.
  - `eventsFound` (number, required) — **Deprecated: read `candidatesScreened`, the same number under a name that says what it counts.** Kept, with the same value, for callers that already read it. It counts candidate items retrieved and screened, NOT events: `eventsFound: 12` with no events to read means 12 candidates were screened and none matched (`candidatesMatched: 0`), not 12 events that are missing.
  - `eventsNew` (number, required) — How many new events THIS RUN WROTE to your workspace. It is the run's execution receipt, not the size of `GET /v1/events?discoveryRunId=`. That list answers the customer's question instead: every live event this run wrote, whatever its event date, plus every one standing for the run's accounts, signals and lookback window, including events written by earlier runs. It can therefore be non-empty, or longer than this count, when `eventsNew` is zero — and on a settled run, read to the end, it is never shorter than this count except where an event has since been withdrawn or deleted. One page is capped at 100, so compare against everything `nextCursor` yields, never against a single page. The list can also be shorter. A safety net downstream of discovery can withdraw an event after the run ends, and a caller can delete one; the frozen receipt still says the run wrote it. Explicit `state=dismissed` reads Trayo's withdrawals in the same scope, while caller-deleted events remain absent. **Act on it once `settledAt` is set, and not before.** Until then the run is still writing events and this number is still rising: a value read from an unsettled run is a partial count, not a small result. Once `settledAt` is set the number is frozen — it is the run's receipt, so it stays what it was even if you later delete some of the events the run created. **`eventsNew: 0` on a `completed` run is a success, not a failure.** Results are deduplicated against everything already in the workspace, so RE-RUNNING AN IDENTICAL DISCOVERY IS SAFE: the same accounts, signals and lookback typically return `eventsNew: 0` on the second call and create no duplicate events. Do not treat a zero here as a reason to retry. Read the scoped events page, `error`, and `blockedSignals`; do not branch on `status` alone — a `completed` run always means the work finished, whatever it found.
  - `blockedSignals` (object[], required) — Signals that did not search some accounts. Each entry identifies the affected accounts in `accountIds` and explains the cause in `reason`. Read this field and `error` before reporting an empty result. These reasons alone do not mark the whole run as failed. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.
    - `signalKey` (string, required) — The signal that produced nothing here, and could not have.
    - `reason` (one of: "account_not_ready" | "company_not_found", required) — Machine-readable cause. Use this field to choose the next action. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.
    - `accountIds` (uuid[], required) — WHICH accounts this signal did not search, so a re-run can name exactly those instead of every account in the run. Truncated to the first 200; `accountsAffected` is always the true total, so `accountIds.length < accountsAffected` means there are more, and the next run reports what is still blocked.
    - `accountsAffected` (integer, required) — How many of the accounts in this run the signal cannot search for this reason. Never truncated, unlike `accountIds`.
    - `accountsRequested` (integer, required) — How many accounts the run was asked to scan, so you can tell a partial blackout from a total one. Equal values mean this signal contributes nothing to this run at all.
    - `message` (string, required) — The same thing in prose, including what to do next.
  - `partial` (boolean, required) — True when the results are INCOMPLETE: at least one account × signal check failed (`failedChecks` lists them), or the run was closed with work still outstanding (`error` says what). `status` stays `completed` on such a run — it says the run stopped, not that every search succeeded. Read `failedChecks` for which signals and accounts, and do not report `eventsNew: 0` on a partial run as "nothing happened". Can turn true while the run is still going; final once `settledAt` is set.
  - `failedChecks` (object, required) — Searches this run STARTED and could not finish — a data source timed out or failed. Unlike `blockedSignals`, which lists searches that could not start, these accounts were eligible and simply went unsearched for these signals. An empty result for them is not evidence that nothing happened.
    - `count` (integer, required) — Account × signal checks that did not finish. A run over 50 accounts and 10 signals makes 500 checks.
    - `bySignal` (object[], required) — The failed checks grouped by signal and reason. Empty on a clean run.
  - `progress` (object, required) — How far the run has come. Equal account numbers mean no account is still being searched; the run is final only when `settledAt` is set, because events are still being written after the last account answers.
    - `accountsFinished` (integer, required) — Accounts that no longer await a result: those that reported back, whatever they found, and those that could not be searched at all (`error` and `blockedSignals` say which). Every account, once the run has settled.
    - `accountsTotal` (integer, required) — Accounts the run was asked to scan.
    - `etaSeconds` (integer | null, required) — Estimated seconds until `settledAt` is set. `0` once the run has settled. `null` when there is no estimate yet: in the first 30 seconds, before a tenth of the accounts have finished, or once the final write step has run longer than expected. Projected from the share of accounts finished so far and bounded by this workspace's recent runs of a similar size. An estimate, not a promise: the run is final only when `settledAt` is set.
    - `estimatedSettleAt` (date-time | null, required) — When the run is expected to settle: the time of this read plus `etaSeconds`. Equal to `settledAt` once the run has settled; `null` whenever `etaSeconds` is.
    - `retryAfterSeconds` (integer | null, required) — How long to wait before reading this run again: close to `etaSeconds`, between 10 and 300 seconds, and 30 when there is no estimate. The same value is sent as the `Retry-After` header. `null` once the run has settled, because there is nothing left to wait for. Do other work in between rather than polling faster.
  - `retry` (one of: "retry_later" | null, required) — `retry_later` on a settled partial run whose failures were transient (a `timeout` or `source_unavailable` check, or work cut short): re-running the same accounts and signals unchanged, later, is the fix. Null otherwise — a clean run needs no retry, a run still going has not finished failing, and a run whose every failure is `internal` would fail the same way.
  - `startedAt` (date-time, required)
  - `completedAt` (date-time | null, required) — When the SEARCH finished — every account in the run reported back. **It is not the end of the run and it is not what to poll on:** the events the search found are written afterwards, so this can be set while `status` is still `running`. Wait for `settledAt`. Null until the search finishes, then stable.
  - `settledAt` (date-time | null, required) — THE FINISH LINE — when the run became final: the search finished AND every event it produced has been written. **Act when `settledAt` is set.** Poll `GET /v1/discoveries/{runId}` until this field is non-null; at that moment `status` reads `completed` or `failed`, the candidate and event counts are frozen for good, and `GET /v1/events?discoveryRunId=` returns every live event this run wrote — at any event date, since a job change is dated by the month the job STARTED and can predate the lookback — plus every one standing for the run's account × signal × lookback scope, including events an earlier run wrote. The counts remain this run's frozen receipt. Do not poll on `completedAt`, and do not try to detect the end by watching `eventsNew` stop changing — a pause between two writes looks exactly like the end of the run and is not one. Null until the run settles, then stable.
  - `error` (string | null, required) — Read this even when `status` is `completed`. A completed run with a non-null `error` ended without doing everything it was asked to — most commonly because none of the requested accounts was eligible (every account must be assigned to a user), which is the single most likely reason a first-time caller sees no new events. The scoped events page can still contain matches written before this run, so this field remains the authority on whether this execution failed.
- `400` → ErrorDto: `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.

`unknown_signal` (`retry: fix_input`) — One or more `signalKeys` do not exist in your workspace. `message` lists them.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: `idempotency_key_reused` (`retry: fix_input`) — This `Idempotency-Key` was already used with a different request body. Repeating this request unchanged will keep failing; use a new key.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/discoveries" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"accountIds":["11111111-1111-4111-8111-111111111111"],"lookbackDays":1}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/discoveries", headers=HEADERS, json={"accountIds":["11111111-1111-4111-8111-111111111111"],"lookbackDays":1})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/discoveries/{runId}

`getDiscovery` — Get a discovery run

**Required permissions:** `events:read`.

THE POLL. Call this until `settledAt` is non-null, then read the events that stand for the run's scope. `settledAt` and `status: completed` arrive together and mean the run is over in full: searched, and every event it produced written. From that moment the candidate and event counts are frozen and `GET /v1/events?discoveryRunId=` returns the complete scope: everything this run wrote, at any event date, plus the account × signal × lookback matches, including events an earlier run wrote. The counters remain about this execution and are not the size of that page — though a settled run paged to the end yields at least `eventsNew`, which one page need not. The same read before settlement returns what the run has written so far. There is no timer to wait out.

Two things NOT to poll on. `completedAt` marks only the first half, the search: it can be set while `status` is still `running`, because the events from that search are still being written. And do not watch `eventsNew` stop changing — a pause between two writes looks exactly like the end of a run and is not one. Read `error` even when `status` is `completed`.

**How long to wait.** `progress` says how many accounts are finished and, once there is enough data, `etaSeconds` and `estimatedSettleAt` estimate when the run settles. While the run is unsettled the response carries a `Retry-After` header (and `progress.retryAfterSeconds`): read the run again after that many seconds rather than on a fixed short timer.

**Parameters**

- `runId` (path, uuid, required)

**Responses**

- `200` → DiscoveryRunDto: The run's current state. Recomputed on every call while the run is live; served from the run's own frozen receipt once `settledAt` is set.
  - `id` (uuid, required)
  - `status` (one of: "running" | "completed" | "failed", required) — `running`, `completed` or `failed`. This is the POLLING signal and nothing else: it tells you when to stop calling, not what the run achieved. Read `error` and `partial` even on a `completed` run. **`completed` means everything this run will ever write has been written.** It covers both halves of the run: the search across your accounts, and the step that turns what the search found into events. It flips at the same instant `settledAt` is set, and from that instant the candidate and event counts are frozen and `GET /v1/events?discoveryRunId=` returns the events that stand for the run's scope: everything it wrote, at any event date, plus matches an earlier run wrote. There is nothing to wait for afterwards. **`running` covers the write step too**, so a run whose search has finished still reads `running` while its events are being written. That is why `completedAt` can be set while `status` is `running`: the search is done, the run is not. Poll on `settledAt` (or on this field reaching `completed`, which is the same moment) and never on `completedAt`. A run that finds nothing reaches `completed` exactly as promptly as one that finds a hundred events.
  - `target` (object, required) — The accounts and signals requested for this run. Older runs can omit these details.
    - `kind` (string, optional)
    - `accountIds` (string[], optional)
    - `signalKeys` (string[] | null, optional)
  - `lookbackDays` (number, required)
  - `maxResults` (number | null, required) — The requested source-result cap this run was started with, before filtering and deduplication. `null` means none was sent, so each source used its default. Google News account searches always use a fixed 10-result budget and ignore `maxResults`; this value is not an event count.
  - `people` (one of: "add" | "none", required) — The `people` option this run was started with: `add` or `none`. A run started before the option existed reads `add`, which is what it did.
  - `accountCount` (number, required) — How many accounts the run was asked to scan.
  - `relatedEvidence` (object, optional) — Optional credential-owned indexed evidence about a related implementation intent. Its candidates never change this run's original events, counts or settlement.
    - `profile` (one of: "ap_p2p_implementation_v1" | "ap_p2p_related_jobs_v2", required)
    - `relation` (one of: "related_candidate" | "alternative_candidate", required) — Related job evidence, never original signal hits. alternative_candidate means the named original signals settled cleanly with zero accounts with events; it does not verify a replacement event.
    - `signalKeys` (string[], required)
    - `families` (object[], optional) — v2 selected families and original signal keys; item attribution is phrase-based only.
    - `status` (one of: "running" | "done" | "unavailable", required)
    - `searchId` (string | null, required) — The existing evidence search, readable only by the credential that started this run. Reading it does not repeat the search.
    - `searchedAccountIds` (string[], required) — Accounts submitted to this pass; native source coverage says what was actually read.
    - `notSearchedAccountIds` (string[], required) — Requested run accounts excluded by eligibility or the existing evidence-search company bound.
    - `sources` (object[], required)
    - `items` (object[], required)
    - `note` (string, required) — Scope and candidate limitations. A posting date, stored active status or prior experience does not establish an ongoing project or an RFP.
  - `candidatesScreened` (integer, required) — How many candidate items this run RETRIEVED and screened: articles, posts and profiles pulled from the sources for your accounts, before anything judged whether they match a signal. **It is not a count of events, and these candidates cannot be read as events.** `candidatesMatched` says how many of them matched a signal, `eventsNew` how many new events the run wrote, and the events themselves are read with `GET /v1/events?discoveryRunId=`. `candidatesScreened: 12` with `candidatesMatched: 0` means 12 items were screened and none matched any signal, so this execution wrote no events and there is nothing of its own to read. The same number as the deprecated `eventsFound`. **Final once `settledAt` is set**, and still moving before that. **`candidatesScreened: 0` on a RE-RUN does not mean nothing was searched.** Items a signal has already seen are dropped before they are stored, so a second identical run reports 0 even though the same sources were queried and returned the same items: on any later run this number says how much was NEW to that signal, not how much was looked at. To tell "nothing was searched" from "nothing was new", use `blockedSignals` and `error`, which do not decay on re-runs.
  - `candidatesMatched` (integer | null, required) — How many of the screened candidates matched a signal and went into an event: a new event, or an event already in your workspace that absorbed them. Several matched candidates can make one event, so this is not an event count either; `eventsNew` is. `candidatesMatched` above zero with `eventsNew: 0` means what matched was already known: the scoped events from `GET /v1/events?discoveryRunId=` hold it. The sum of `bySignal[].candidatesMatched`. Final once `settledAt` is set. Null only on a run settled before this field existed.
  - `bySignal` (object[] | null, required) — The screened and matched candidates per signal the run covered, and the accounts each fired at (`accountsWithEvents`, the fire-rate numerator), sorted by `signalKey`. Every signal is listed, including one that screened or matched nothing, so a signal that matched no account reads `candidatesMatched: 0` here rather than being absent. A signal with `candidatesScreened: 0` may not have searched at all: check `blockedSignals` and `failedChecks` for it. Empty when the run searched nothing (`error` says why). Final once `settledAt` is set. Null only on a run settled before this field existed.
  - `eventsFound` (number, required) — **Deprecated: read `candidatesScreened`, the same number under a name that says what it counts.** Kept, with the same value, for callers that already read it. It counts candidate items retrieved and screened, NOT events: `eventsFound: 12` with no events to read means 12 candidates were screened and none matched (`candidatesMatched: 0`), not 12 events that are missing.
  - `eventsNew` (number, required) — How many new events THIS RUN WROTE to your workspace. It is the run's execution receipt, not the size of `GET /v1/events?discoveryRunId=`. That list answers the customer's question instead: every live event this run wrote, whatever its event date, plus every one standing for the run's accounts, signals and lookback window, including events written by earlier runs. It can therefore be non-empty, or longer than this count, when `eventsNew` is zero — and on a settled run, read to the end, it is never shorter than this count except where an event has since been withdrawn or deleted. One page is capped at 100, so compare against everything `nextCursor` yields, never against a single page. The list can also be shorter. A safety net downstream of discovery can withdraw an event after the run ends, and a caller can delete one; the frozen receipt still says the run wrote it. Explicit `state=dismissed` reads Trayo's withdrawals in the same scope, while caller-deleted events remain absent. **Act on it once `settledAt` is set, and not before.** Until then the run is still writing events and this number is still rising: a value read from an unsettled run is a partial count, not a small result. Once `settledAt` is set the number is frozen — it is the run's receipt, so it stays what it was even if you later delete some of the events the run created. **`eventsNew: 0` on a `completed` run is a success, not a failure.** Results are deduplicated against everything already in the workspace, so RE-RUNNING AN IDENTICAL DISCOVERY IS SAFE: the same accounts, signals and lookback typically return `eventsNew: 0` on the second call and create no duplicate events. Do not treat a zero here as a reason to retry. Read the scoped events page, `error`, and `blockedSignals`; do not branch on `status` alone — a `completed` run always means the work finished, whatever it found.
  - `blockedSignals` (object[], required) — Signals that did not search some accounts. Each entry identifies the affected accounts in `accountIds` and explains the cause in `reason`. Read this field and `error` before reporting an empty result. These reasons alone do not mark the whole run as failed. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.
    - `signalKey` (string, required) — The signal that produced nothing here, and could not have.
    - `reason` (one of: "account_not_ready" | "company_not_found", required) — Machine-readable cause. Use this field to choose the next action. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.
    - `accountIds` (uuid[], required) — WHICH accounts this signal did not search, so a re-run can name exactly those instead of every account in the run. Truncated to the first 200; `accountsAffected` is always the true total, so `accountIds.length < accountsAffected` means there are more, and the next run reports what is still blocked.
    - `accountsAffected` (integer, required) — How many of the accounts in this run the signal cannot search for this reason. Never truncated, unlike `accountIds`.
    - `accountsRequested` (integer, required) — How many accounts the run was asked to scan, so you can tell a partial blackout from a total one. Equal values mean this signal contributes nothing to this run at all.
    - `message` (string, required) — The same thing in prose, including what to do next.
  - `partial` (boolean, required) — True when the results are INCOMPLETE: at least one account × signal check failed (`failedChecks` lists them), or the run was closed with work still outstanding (`error` says what). `status` stays `completed` on such a run — it says the run stopped, not that every search succeeded. Read `failedChecks` for which signals and accounts, and do not report `eventsNew: 0` on a partial run as "nothing happened". Can turn true while the run is still going; final once `settledAt` is set.
  - `failedChecks` (object, required) — Searches this run STARTED and could not finish — a data source timed out or failed. Unlike `blockedSignals`, which lists searches that could not start, these accounts were eligible and simply went unsearched for these signals. An empty result for them is not evidence that nothing happened.
    - `count` (integer, required) — Account × signal checks that did not finish. A run over 50 accounts and 10 signals makes 500 checks.
    - `bySignal` (object[], required) — The failed checks grouped by signal and reason. Empty on a clean run.
  - `progress` (object, required) — How far the run has come. Equal account numbers mean no account is still being searched; the run is final only when `settledAt` is set, because events are still being written after the last account answers.
    - `accountsFinished` (integer, required) — Accounts that no longer await a result: those that reported back, whatever they found, and those that could not be searched at all (`error` and `blockedSignals` say which). Every account, once the run has settled.
    - `accountsTotal` (integer, required) — Accounts the run was asked to scan.
    - `etaSeconds` (integer | null, required) — Estimated seconds until `settledAt` is set. `0` once the run has settled. `null` when there is no estimate yet: in the first 30 seconds, before a tenth of the accounts have finished, or once the final write step has run longer than expected. Projected from the share of accounts finished so far and bounded by this workspace's recent runs of a similar size. An estimate, not a promise: the run is final only when `settledAt` is set.
    - `estimatedSettleAt` (date-time | null, required) — When the run is expected to settle: the time of this read plus `etaSeconds`. Equal to `settledAt` once the run has settled; `null` whenever `etaSeconds` is.
    - `retryAfterSeconds` (integer | null, required) — How long to wait before reading this run again: close to `etaSeconds`, between 10 and 300 seconds, and 30 when there is no estimate. The same value is sent as the `Retry-After` header. `null` once the run has settled, because there is nothing left to wait for. Do other work in between rather than polling faster.
  - `retry` (one of: "retry_later" | null, required) — `retry_later` on a settled partial run whose failures were transient (a `timeout` or `source_unavailable` check, or work cut short): re-running the same accounts and signals unchanged, later, is the fix. Null otherwise — a clean run needs no retry, a run still going has not finished failing, and a run whose every failure is `internal` would fail the same way.
  - `startedAt` (date-time, required)
  - `completedAt` (date-time | null, required) — When the SEARCH finished — every account in the run reported back. **It is not the end of the run and it is not what to poll on:** the events the search found are written afterwards, so this can be set while `status` is still `running`. Wait for `settledAt`. Null until the search finishes, then stable.
  - `settledAt` (date-time | null, required) — THE FINISH LINE — when the run became final: the search finished AND every event it produced has been written. **Act when `settledAt` is set.** Poll `GET /v1/discoveries/{runId}` until this field is non-null; at that moment `status` reads `completed` or `failed`, the candidate and event counts are frozen for good, and `GET /v1/events?discoveryRunId=` returns every live event this run wrote — at any event date, since a job change is dated by the month the job STARTED and can predate the lookback — plus every one standing for the run's account × signal × lookback scope, including events an earlier run wrote. The counts remain this run's frozen receipt. Do not poll on `completedAt`, and do not try to detect the end by watching `eventsNew` stop changing — a pause between two writes looks exactly like the end of the run and is not one. Null until the run settles, then stable.
  - `error` (string | null, required) — Read this even when `status` is `completed`. A completed run with a non-null `error` ended without doing everything it was asked to — most commonly because none of the requested accounts was eligible (every account must be assigned to a user), which is the single most likely reason a first-time caller sees no new events. The scoped events page can still contain matches written before this run, so this field remains the authority on whether this execution failed.
- `400` → ErrorDto: `runId` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `discovery_run_not_found` (`retry: do_not_retry`) — No such run in your workspace.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {runId} with the real value.
curl "$TRAYO_API_URL/v1/discoveries/{runId}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

run_id = "..."  # the {runId} in the path
r = requests.get(f"{BASE}/v1/discoveries/{run_id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Events

What discovery found. Every event names the signals it matched in `signalKeys`, so one run over many signals is attributable event by event. Delete one to dismiss it; it will not come back.

### GET /v1/events

`listEvents` — List events

**Required permissions:** `events:read`.

What discovery found. Defaults to `state=live` — dismissed events are excluded unless you ask for them explicitly. PAGINATES: follow `nextCursor` until `hasMore` is false.

After a discovery run reports `status: completed`, filter by `?discoveryRunId={runId}` to read every event that stands for that run: everything the run wrote, at any `eventDate` — a job change carries the month the job STARTED, which can predate the lookback — plus every event matching its account × signal × lookback scope, including ones written by earlier runs. Paged to the end, a settled run therefore yields at least `eventsNew` events — one page is capped and need not, so follow `nextCursor` before comparing to that counter. Before it settles the same filter returns what the run has written so far; poll `GET /v1/discoveries/{runId}` for the rest. `candidatesScreened` and `eventsNew` remain the execution's frozen counters, not the size of this page.

**Parameters**

- `discoveryRunId` (query, uuid, optional) — Restrict to events that stand for one discovery run. That is the union of two sets: every event the run itself has written — all of them, whatever their `eventDate`, because a job change is dated by the month the job STARTED and can predate the lookback — and the canonical events that match its accounts, its signals and the event-date window from `startedAt - lookbackDays` through `settledAt`, including ones an earlier run wrote. So the page can be non-empty when the run reports `eventsNew: 0`, and a settled run paged to the end — follow `nextCursor` until `hasMore` is false — yields at least `eventsNew` events, which one page of at most 100 need not; the run counters remain about that execution. Before the run settles this returns what it has written so far — poll for the rest. Additional account, signal, or date filters only narrow this scope. Use the `id` from `POST`/`GET /v1/discoveries`; a run that does not resolve in your workspace is a 404 (`discovery_run_not_found`). One limit worth knowing: the out-of-window half covers what THIS run wrote. An event an earlier run wrote outside this run's window, and that this run merely re-detected, is matched to that earlier row and stays outside — read it by `accountId` and `signalKey` instead.
- `accountId` (query, uuid, optional) — Restrict to events on one account.
- `signalKey` (query, string, optional) — Restrict to events matched to this signal.
- `signalKeys` (query, string[], optional) — Restrict to events matched to ANY of these signals — the union, not the intersection. A check of several signals needs one call rather than one per key. Pair it with `discoveredSince` to read events found after your previous check. Send this or `signalKey`, not both.
- `since` (query, string, optional) — Send a plain date — `2026-06-07`, read as midnight UTC — or a full ISO-8601 timestamp — `2026-06-07T14:30:00Z` — when you need the time of day. **Relative words are not accepted:** `yesterday`, `last-quarter`, `7d` and anything else that has to be resolved against "now" is rejected with a 400 (`invalid_date`). Compute the date yourself and send it. Only events that HAPPENED at or after this value — `eventDate`, the date carried by the event itself. Ask it when the age of what happened is what matters to you. **For events found after your previous check, use `discoveredSince` instead.** An article published last month can reach your workspace today, and this filter will not show it to you.
- `discoveredSince` (query, string, optional) — Send a plain date — `2026-06-07`, read as midnight UTC — or a full ISO-8601 timestamp — `2026-06-07T14:30:00Z` — when you need the time of day. **Relative words are not accepted:** `yesterday`, `last-quarter`, `7d` and anything else that has to be resolved against "now" is rejected with a 400 (`invalid_date`). Compute the date yourself and send it. Only events Trayo FOUND at or after this value — `createdAt` on every row, and the order this list already comes back in. **Use this to read events found since your previous check.** Send the moment you last looked and you get everything new since then, whatever date the events themselves carry — news that happened before the previous check included. `since` answers the other question, when the event happened; send both and an event has to satisfy both.
- `state` (query, string, optional) — Defaults to `live` — dismissed events are excluded unless you ask for them. **`dismissed` is Trayo's own verdict,** not yours: an event discovery produced and then withdrew. Nothing a caller does moves an event into it, so it is normally empty for a workspace whose events all come from this API. It stays readable with `?state=dismissed`. **To dismiss an event yourself, send `DELETE /v1/events/{id}`** — the same action as the dismiss button in the Trayo app. Nothing a discovery run wrote is permanent. An event dismissed that way does **not** turn up under `?state=dismissed`: it leaves both states, its id 404s afterwards, and it stays gone, because discovery deduplicates against every event in the workspace, the ones you dismissed included, so no later run recreates it. So the two readable states answer different questions: `live` is what stands, `dismissed` is what Trayo withdrew, and what you dismissed is in neither.
- `expand` (query, string, optional) — Send `expand=people,signals` to add the `people` and `signals` arrays to every item in `data`. Despite the comma-separated syntax, expansion is currently ALL-OR-NOTHING: any non-empty value — `people` alone, `signals` alone, even `expand=bogus` — expands both fields together. There is no way to request only one. Omit the parameter entirely for the compact form.
- `cursor` (query, string, optional) — Opaque pagination cursor from a previous page's `nextCursor`. Omit for the first page. Do not construct or parse this value — it is a base64url-encoded implementation detail and may change shape without notice.
- `limit` (query, integer, optional) — Page size, default 25. Values above 100 are silently CAPPED to 100, not rejected. Note the deliberate contrast with the two other bounds in this API: `POST /v1/find`'s `limit` is rejected above 100 (and a sentence search returns at most 50), and a `/v1/…/batch` array of more than 200 items is an ERROR rather than a clamp — silently dropping rows would lose records the caller believes it created. A `limit` that is zero, negative or not an integer is rejected here too; only the upper bound clamps.

**Responses**

- `200` → EventListDto: 
  - `data` (object[], required) — Events, most recently created first.
    - `id` (uuid, required)
    - `accountId` (uuid, required)
    - `accountName` (string, required) — The account this event is about, named. On every event, expanded or not, so a digest can say which company without a second call — there is no route that turns an `accountId` back into a company.
    - `accountUrl` (uri | null, required) — That account's website as an absolute HTTP or HTTPS URL, or `null` when the workspace does not know one — same field, and same rule, as `url` on `GET /v1/accounts`.
    - `title` (string, required)
    - `summary` (string | null, required)
    - `sourceObservation` (object | null, optional) — Source summary with its quotations, acquired headline, or null when no retained receipt exists; separate from seller relevance.
    - `whyItMatters` (string | null, required) — Why this event matters to YOUR workspace — one short paragraph relating the event to your solutions and the signals it matched, written by discovery when it wrote the event. `summary` says what happened; this says why you should care. It is the same text the Trayo app shows under "Why it matters" and that webhook payloads carry as `whyItMatters`. For jobs and job changes, this field restates the recorded posting or career facts, preserving the stated start-date precision. Seller relevance in offerings is labelled "Possible reason:". `null` when discovery did not produce one — older events, and events whose signal type skips that stage. Never an empty string.
    - `offerings` (object[] | null, required) — Which of YOUR solutions this event relates to — the offerings from your workspace profile that discovery tied to the event when it wrote `whyItMatters`. The same array webhook payloads carry as `offerings`. `null`, never `[]`, when discovery tied none to it: older events, events whose signal type skips that stage, and workspaces with no offerings on their profile (`PATCH /v1/workspace`).
    - `url` (string | null, required)
    - `publication` (string | null, required)
    - `eventDate` (date-time, required)
    - `signalType` (string, required)
    - `signalKeys` (string[], required) — Which of YOUR signals matched this event, by `signalKey` — the slug you named the signal, the same value `POST /v1/discoveries` takes in its `signalKeys` and `?signalKey=` filters on here. On EVERY event, compact reads included, sorted and without repeats so two reads of one event agree. **This is the per-event attribution `signalType` does not give you.** `signalType` is the kind of source a signal reads — `news`, `jobs`, `job_change` — and your signals usually share a handful of those between them; `signalKeys` names the signals themselves. So a run over many signals stays attributable event by event, and a run per signal is not needed to tell which one fired. Usually one key. Several when several of your signals matched the same event, which Trayo records on the one event rather than duplicating it. `[]` only where no signal is linked at all: an event from before these links were recorded, or one whose signal has since been deleted. `expand=signals` carries the same keys with each match's `type`, `confidence` and `evidence`; this field is the keys alone, and it costs no expansion.
    - `jobChange` (object | null, required) — The move behind a `job_change` event — the same object webhook payloads carry as `jobChange`. `null` on every event whose `signalType` is not `job_change`. On a `job_change` event the object is always present, and its own fields read `null` where discovery could not classify the move.
    - `location` (object | null, required) — Where the job posting behind a `jobs` event says the role is, as the posting records it. State a role's location from here, never from its title: a role that asks for a language is often based in another country. A job listed in several places shows the place of the listing that stands for it; `jobPosting.locations` holds every place. A part reads `null` when the posting leaves it empty. The object is `null` when the posting records no place and does not say it is remote, and on every event whose `signalType` is not `jobs`.
    - `jobPosting` (object | null, required) — The job behind a `jobs` event, over all its listings. Its `eventDate` is the earliest date the job was posted: the date a listing says it was posted when that is not after the day it was first recorded, else that day. A job first posted before the window you asked for is an older job listed again, not a new opening. `null` on every event whose `signalType` is not `jobs`, and on jobs events discovery wrote before it grouped listings (2026-10).
    - `state` (one of: "live" | "dismissed", required) — The only two states this API publishes. An event that has not reached one of them is not addressable yet — it 404s. `dismissed` means Trayo withdrew an event it had produced. To dismiss one yourself send `DELETE /v1/events/{id}`; the event then reads back in neither state.
    - `dismissalReason` (string | null, required) — Why Trayo withdrew this event — a short machine-readable tag such as `stale_job_change`, `no_signal_match` or `within_batch_duplicate:{eventId}`. The vocabulary is Trayo's own and grows over time, so read it as a label, not an enum. Only ever set on an event whose `state` is `dismissed`, and only when a reason was recorded. `null` on every `live` event.
    - `createdAt` (date-time, required)
    - `people` (object[], optional) — Present only when `expand` was sent on the list route. Always present on `GET /v1/events/{id}`. **It is EMPTY until three preconditions hold, and an empty array here does not mean nobody was relevant.** Attaching people to an EVENT is a stage of its own. It runs only when (1) the account is ready — the same readiness `blockedSignals` reports on a discovery run, which the account reaches on its own shortly after it is created — (2) the workspace has a stakeholder definition, and (3) a discovery run you start yourself was started with `people: "add"`. The default, `people: "none"`, adds no people. A `job_change` event still names the person who changed jobs; add them with `POST /v1/people` if you want them. (2) and (3) are yours: `PATCH /v1/workspace`, and `people: "add"` on the run. All must be in place BEFORE the discovery run that produces the event — none is applied retroactively — so a workspace that sets its definition after its first runs gets `[]` on the events those runs produced and people on the ones after. Set it first. The people listed here are already in your workspace: `id` is a person id you can pass to list and contact tools without adding them again. Importing an account adds no people; to add people at an account yourself, search with `POST /v1/find` and add the ones you choose with `POST /v1/people/batch`. `signals` expands normally.
    - `signals` (object[], optional) — How well each of your signals matched, and from what reasoning. Same expansion rule as `people`. Send `expand` for that per-match detail, not to learn WHICH signals fired — every event names those in `signalKeys`, expanded or not. One entry per stored match, ordered by `signalKey` like `signalKeys`, but NOT deduplicated the way that field is: a signal you have refined can hold two matches on one event, one per version of it. Such repeats carry IDENTICAL `confidence` and `evidence`, because the detail is looked up by `signalKey` — so a repeated key tells you nothing extra. Expect repeats and read `signalKeys` for the distinct set.
  - `hasMore` (boolean, required) — Another page exists. When `false` you have reached the end — stop requesting pages.
  - `nextCursor` (string | null, required) — Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.

`invalid_date` (`retry: fix_input`) — A date filter (`updatedSince`, `since`, `detectedSince`) did not parse. Send a plain date (`2026-06-07`, read as midnight UTC) or a full ISO-8601 timestamp (`2026-06-07T14:30:00Z`). Relative words such as `yesterday` or `last-quarter` are not accepted. `param` names the field.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: `discoveryRunId` does not resolve in your workspace.

`discovery_run_not_found` (`retry: do_not_retry`) — No such run in your workspace.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/events" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/events", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/events/{id}

`getEvent` — Get an event

**Required permissions:** `events:read`.

Always returns `people` and `signals` expanded — unlike the list route, there is no `expand` parameter here because it is not needed.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → EventDto: The event, always expanded.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `accountName` (string, required) — The account this event is about, named. On every event, expanded or not, so a digest can say which company without a second call — there is no route that turns an `accountId` back into a company.
  - `accountUrl` (uri | null, required) — That account's website as an absolute HTTP or HTTPS URL, or `null` when the workspace does not know one — same field, and same rule, as `url` on `GET /v1/accounts`.
  - `title` (string, required)
  - `summary` (string | null, required)
  - `sourceObservation` (object | null, optional) — Source summary with its quotations, acquired headline, or null when no retained receipt exists; separate from seller relevance.
    - `basis` (one of: "source_quotes" | "source_headline" | "court_docket", required)
    - `text` (string, required)
    - `quotes` (string[], required)
    - `scopeNote` (string, required)
  - `whyItMatters` (string | null, required) — Why this event matters to YOUR workspace — one short paragraph relating the event to your solutions and the signals it matched, written by discovery when it wrote the event. `summary` says what happened; this says why you should care. It is the same text the Trayo app shows under "Why it matters" and that webhook payloads carry as `whyItMatters`. For jobs and job changes, this field restates the recorded posting or career facts, preserving the stated start-date precision. Seller relevance in offerings is labelled "Possible reason:". `null` when discovery did not produce one — older events, and events whose signal type skips that stage. Never an empty string.
  - `offerings` (object[] | null, required) — Which of YOUR solutions this event relates to — the offerings from your workspace profile that discovery tied to the event when it wrote `whyItMatters`. The same array webhook payloads carry as `offerings`. `null`, never `[]`, when discovery tied none to it: older events, events whose signal type skips that stage, and workspaces with no offerings on their profile (`PATCH /v1/workspace`).
  - `url` (string | null, required)
  - `publication` (string | null, required)
  - `eventDate` (date-time, required)
  - `signalType` (string, required)
  - `signalKeys` (string[], required) — Which of YOUR signals matched this event, by `signalKey` — the slug you named the signal, the same value `POST /v1/discoveries` takes in its `signalKeys` and `?signalKey=` filters on here. On EVERY event, compact reads included, sorted and without repeats so two reads of one event agree. **This is the per-event attribution `signalType` does not give you.** `signalType` is the kind of source a signal reads — `news`, `jobs`, `job_change` — and your signals usually share a handful of those between them; `signalKeys` names the signals themselves. So a run over many signals stays attributable event by event, and a run per signal is not needed to tell which one fired. Usually one key. Several when several of your signals matched the same event, which Trayo records on the one event rather than duplicating it. `[]` only where no signal is linked at all: an event from before these links were recorded, or one whose signal has since been deleted. `expand=signals` carries the same keys with each match's `type`, `confidence` and `evidence`; this field is the keys alone, and it costs no expansion.
  - `jobChange` (object | null, required) — The move behind a `job_change` event — the same object webhook payloads carry as `jobChange`. `null` on every event whose `signalType` is not `job_change`. On a `job_change` event the object is always present, and its own fields read `null` where discovery could not classify the move.
    - `type` (string | null, required) — How the person moved — for example `new_hire` or `promotion`.
    - `date` (string | null, required) — When the move happened, as discovery read it off the profile.
    - `previousRole` (object | null, required) — Where they came from. `null` when discovery found no prior role.
  - `location` (object | null, required) — Where the job posting behind a `jobs` event says the role is, as the posting records it. State a role's location from here, never from its title: a role that asks for a language is often based in another country. A job listed in several places shows the place of the listing that stands for it; `jobPosting.locations` holds every place. A part reads `null` when the posting leaves it empty. The object is `null` when the posting records no place and does not say it is remote, and on every event whose `signalType` is not `jobs`.
    - `city` (string | null, required)
    - `region` (string | null, required) — The state, province or similar region.
    - `country` (string | null, required)
    - `remote` (boolean, required) — Whether the posting says the role is remote. When true, the city, region and country are where the posting is listed, not where the person would work.
  - `jobPosting` (object | null, required) — The job behind a `jobs` event, over all its listings. Its `eventDate` is the earliest date the job was posted: the date a listing says it was posted when that is not after the day it was first recorded, else that day. A job first posted before the window you asked for is an older job listed again, not a new opening. `null` on every event whose `signalType` is not `jobs`, and on jobs events discovery wrote before it grouped listings (2026-10).
    - `listings` (integer, required) — How many listings of this one job discovery found. A job board lists one role once per city, and several boards list it again: they are one job and one event. Count roles by events, never by listings.
    - `locations` (object[], required) — Every place those listings name, each once, `location` first. `[]` when none records a place.
    - `active` (boolean | null, required) — Whether any listing was still open when discovery found the job. `false`: every listing had closed, so report it as a past opening, never as current hiring. `null` when the listings did not say.
    - `firstSeen` (string | null, required) — The day the first listing was recorded, YYYY-MM-DD. `null` when not recorded.
    - `lastSeen` (string | null, required) — The day the latest listing was recorded, YYYY-MM-DD. `null` when not recorded.
  - `state` (one of: "live" | "dismissed", required) — The only two states this API publishes. An event that has not reached one of them is not addressable yet — it 404s. `dismissed` means Trayo withdrew an event it had produced. To dismiss one yourself send `DELETE /v1/events/{id}`; the event then reads back in neither state.
  - `dismissalReason` (string | null, required) — Why Trayo withdrew this event — a short machine-readable tag such as `stale_job_change`, `no_signal_match` or `within_batch_duplicate:{eventId}`. The vocabulary is Trayo's own and grows over time, so read it as a label, not an enum. Only ever set on an event whose `state` is `dismissed`, and only when a reason was recorded. `null` on every `live` event.
  - `createdAt` (date-time, required)
  - `people` (object[], optional) — Present only when `expand` was sent on the list route. Always present on `GET /v1/events/{id}`. **It is EMPTY until three preconditions hold, and an empty array here does not mean nobody was relevant.** Attaching people to an EVENT is a stage of its own. It runs only when (1) the account is ready — the same readiness `blockedSignals` reports on a discovery run, which the account reaches on its own shortly after it is created — (2) the workspace has a stakeholder definition, and (3) a discovery run you start yourself was started with `people: "add"`. The default, `people: "none"`, adds no people. A `job_change` event still names the person who changed jobs; add them with `POST /v1/people` if you want them. (2) and (3) are yours: `PATCH /v1/workspace`, and `people: "add"` on the run. All must be in place BEFORE the discovery run that produces the event — none is applied retroactively — so a workspace that sets its definition after its first runs gets `[]` on the events those runs produced and people on the ones after. Set it first. The people listed here are already in your workspace: `id` is a person id you can pass to list and contact tools without adding them again. Importing an account adds no people; to add people at an account yourself, search with `POST /v1/find` and add the ones you choose with `POST /v1/people/batch`. `signals` expands normally.
    - `id` (uuid, required)
    - `fullName` (string, required)
    - `title` (string | null, required)
    - `linkedinUrl` (string | null, 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`.
    - `photoUrl` (string | null, 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.
    - `reasoning` (string, required) — Why this person is relevant to this event.
  - `signals` (object[], optional) — How well each of your signals matched, and from what reasoning. Same expansion rule as `people`. Send `expand` for that per-match detail, not to learn WHICH signals fired — every event names those in `signalKeys`, expanded or not. One entry per stored match, ordered by `signalKey` like `signalKeys`, but NOT deduplicated the way that field is: a signal you have refined can hold two matches on one event, one per version of it. Such repeats carry IDENTICAL `confidence` and `evidence`, because the detail is looked up by `signalKey` — so a repeated key tells you nothing extra. Expect repeats and read `signalKeys` for the distinct set.
    - `signalKey` (string, required)
    - `type` (string, required)
    - `confidence` (one of: "low" | "medium" | "high" | null, required) — How sure the match was, on the three-step scale the matcher writes: `low`, `medium`, `high`. `null` when the run did not record one for this signal — older events, and signals attached by a stage that does not score its matches.
    - `evidence` (string | null, required) — Why this signal matched THIS event, in the matcher's own words — the sentence or two it reasoned from. `whyItMatters` argues the event to your workspace; this shows the work behind one signal. For jobs and job changes, both fields instead restate the retained posting or career facts, or are null when none remain. `null` when the run recorded no reasoning for this signal. Never an empty string.
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: No such event, it belongs to another workspace, or it exists but has not become available yet. All three are indistinguishable.

`event_not_found` (`retry: do_not_retry`) — No such event in your workspace, or it has not finished processing and is not addressable yet.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl "$TRAYO_API_URL/v1/events/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.get(f"{BASE}/v1/events/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### DELETE /v1/events/{id}

`deleteEvent` — Delete (dismiss) an event

**Required permissions:** `events:write`.

Soft delete — the same action as dismissing the event in the Trayo app. Returns 200, not 204, with the event as it was, exactly like the other delete routes here; the proof is behavioral: this id now 404s and the event no longer appears in `GET /v1/events` under any `state`.

**It stays deleted.** Discovery deduplicates against every event in the workspace, deleted ones included, so a later run does not recreate it. Use this to tell Trayo an event was wrong or irrelevant; an event you merely ignore keeps counting toward the account's `scores`.

**Not the same as `state: dismissed`.** That state means Trayo withdrew the event. It is still readable with `?state=dismissed`. A delete is yours, and is not readable afterwards.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → EventDto: The event as it existed immediately before deletion.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `accountName` (string, required) — The account this event is about, named. On every event, expanded or not, so a digest can say which company without a second call — there is no route that turns an `accountId` back into a company.
  - `accountUrl` (uri | null, required) — That account's website as an absolute HTTP or HTTPS URL, or `null` when the workspace does not know one — same field, and same rule, as `url` on `GET /v1/accounts`.
  - `title` (string, required)
  - `summary` (string | null, required)
  - `sourceObservation` (object | null, optional) — Source summary with its quotations, acquired headline, or null when no retained receipt exists; separate from seller relevance.
    - `basis` (one of: "source_quotes" | "source_headline" | "court_docket", required)
    - `text` (string, required)
    - `quotes` (string[], required)
    - `scopeNote` (string, required)
  - `whyItMatters` (string | null, required) — Why this event matters to YOUR workspace — one short paragraph relating the event to your solutions and the signals it matched, written by discovery when it wrote the event. `summary` says what happened; this says why you should care. It is the same text the Trayo app shows under "Why it matters" and that webhook payloads carry as `whyItMatters`. For jobs and job changes, this field restates the recorded posting or career facts, preserving the stated start-date precision. Seller relevance in offerings is labelled "Possible reason:". `null` when discovery did not produce one — older events, and events whose signal type skips that stage. Never an empty string.
  - `offerings` (object[] | null, required) — Which of YOUR solutions this event relates to — the offerings from your workspace profile that discovery tied to the event when it wrote `whyItMatters`. The same array webhook payloads carry as `offerings`. `null`, never `[]`, when discovery tied none to it: older events, events whose signal type skips that stage, and workspaces with no offerings on their profile (`PATCH /v1/workspace`).
  - `url` (string | null, required)
  - `publication` (string | null, required)
  - `eventDate` (date-time, required)
  - `signalType` (string, required)
  - `signalKeys` (string[], required) — Which of YOUR signals matched this event, by `signalKey` — the slug you named the signal, the same value `POST /v1/discoveries` takes in its `signalKeys` and `?signalKey=` filters on here. On EVERY event, compact reads included, sorted and without repeats so two reads of one event agree. **This is the per-event attribution `signalType` does not give you.** `signalType` is the kind of source a signal reads — `news`, `jobs`, `job_change` — and your signals usually share a handful of those between them; `signalKeys` names the signals themselves. So a run over many signals stays attributable event by event, and a run per signal is not needed to tell which one fired. Usually one key. Several when several of your signals matched the same event, which Trayo records on the one event rather than duplicating it. `[]` only where no signal is linked at all: an event from before these links were recorded, or one whose signal has since been deleted. `expand=signals` carries the same keys with each match's `type`, `confidence` and `evidence`; this field is the keys alone, and it costs no expansion.
  - `jobChange` (object | null, required) — The move behind a `job_change` event — the same object webhook payloads carry as `jobChange`. `null` on every event whose `signalType` is not `job_change`. On a `job_change` event the object is always present, and its own fields read `null` where discovery could not classify the move.
    - `type` (string | null, required) — How the person moved — for example `new_hire` or `promotion`.
    - `date` (string | null, required) — When the move happened, as discovery read it off the profile.
    - `previousRole` (object | null, required) — Where they came from. `null` when discovery found no prior role.
  - `location` (object | null, required) — Where the job posting behind a `jobs` event says the role is, as the posting records it. State a role's location from here, never from its title: a role that asks for a language is often based in another country. A job listed in several places shows the place of the listing that stands for it; `jobPosting.locations` holds every place. A part reads `null` when the posting leaves it empty. The object is `null` when the posting records no place and does not say it is remote, and on every event whose `signalType` is not `jobs`.
    - `city` (string | null, required)
    - `region` (string | null, required) — The state, province or similar region.
    - `country` (string | null, required)
    - `remote` (boolean, required) — Whether the posting says the role is remote. When true, the city, region and country are where the posting is listed, not where the person would work.
  - `jobPosting` (object | null, required) — The job behind a `jobs` event, over all its listings. Its `eventDate` is the earliest date the job was posted: the date a listing says it was posted when that is not after the day it was first recorded, else that day. A job first posted before the window you asked for is an older job listed again, not a new opening. `null` on every event whose `signalType` is not `jobs`, and on jobs events discovery wrote before it grouped listings (2026-10).
    - `listings` (integer, required) — How many listings of this one job discovery found. A job board lists one role once per city, and several boards list it again: they are one job and one event. Count roles by events, never by listings.
    - `locations` (object[], required) — Every place those listings name, each once, `location` first. `[]` when none records a place.
    - `active` (boolean | null, required) — Whether any listing was still open when discovery found the job. `false`: every listing had closed, so report it as a past opening, never as current hiring. `null` when the listings did not say.
    - `firstSeen` (string | null, required) — The day the first listing was recorded, YYYY-MM-DD. `null` when not recorded.
    - `lastSeen` (string | null, required) — The day the latest listing was recorded, YYYY-MM-DD. `null` when not recorded.
  - `state` (one of: "live" | "dismissed", required) — The only two states this API publishes. An event that has not reached one of them is not addressable yet — it 404s. `dismissed` means Trayo withdrew an event it had produced. To dismiss one yourself send `DELETE /v1/events/{id}`; the event then reads back in neither state.
  - `dismissalReason` (string | null, required) — Why Trayo withdrew this event — a short machine-readable tag such as `stale_job_change`, `no_signal_match` or `within_batch_duplicate:{eventId}`. The vocabulary is Trayo's own and grows over time, so read it as a label, not an enum. Only ever set on an event whose `state` is `dismissed`, and only when a reason was recorded. `null` on every `live` event.
  - `createdAt` (date-time, required)
  - `people` (object[], optional) — Present only when `expand` was sent on the list route. Always present on `GET /v1/events/{id}`. **It is EMPTY until three preconditions hold, and an empty array here does not mean nobody was relevant.** Attaching people to an EVENT is a stage of its own. It runs only when (1) the account is ready — the same readiness `blockedSignals` reports on a discovery run, which the account reaches on its own shortly after it is created — (2) the workspace has a stakeholder definition, and (3) a discovery run you start yourself was started with `people: "add"`. The default, `people: "none"`, adds no people. A `job_change` event still names the person who changed jobs; add them with `POST /v1/people` if you want them. (2) and (3) are yours: `PATCH /v1/workspace`, and `people: "add"` on the run. All must be in place BEFORE the discovery run that produces the event — none is applied retroactively — so a workspace that sets its definition after its first runs gets `[]` on the events those runs produced and people on the ones after. Set it first. The people listed here are already in your workspace: `id` is a person id you can pass to list and contact tools without adding them again. Importing an account adds no people; to add people at an account yourself, search with `POST /v1/find` and add the ones you choose with `POST /v1/people/batch`. `signals` expands normally.
    - `id` (uuid, required)
    - `fullName` (string, required)
    - `title` (string | null, required)
    - `linkedinUrl` (string | null, 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`.
    - `photoUrl` (string | null, 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.
    - `reasoning` (string, required) — Why this person is relevant to this event.
  - `signals` (object[], optional) — How well each of your signals matched, and from what reasoning. Same expansion rule as `people`. Send `expand` for that per-match detail, not to learn WHICH signals fired — every event names those in `signalKeys`, expanded or not. One entry per stored match, ordered by `signalKey` like `signalKeys`, but NOT deduplicated the way that field is: a signal you have refined can hold two matches on one event, one per version of it. Such repeats carry IDENTICAL `confidence` and `evidence`, because the detail is looked up by `signalKey` — so a repeated key tells you nothing extra. Expect repeats and read `signalKeys` for the distinct set.
    - `signalKey` (string, required)
    - `type` (string, required)
    - `confidence` (one of: "low" | "medium" | "high" | null, required) — How sure the match was, on the three-step scale the matcher writes: `low`, `medium`, `high`. `null` when the run did not record one for this signal — older events, and signals attached by a stage that does not score its matches.
    - `evidence` (string | null, required) — Why this signal matched THIS event, in the matcher's own words — the sentence or two it reasoned from. `whyItMatters` argues the event to your workspace; this shows the work behind one signal. For jobs and job changes, both fields instead restate the retained posting or career facts, or are null when none remain. `null` when the run recorded no reasoning for this signal. Never an empty string.
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: No such event, it belongs to another workspace, it was already deleted, or it has not reached a public state yet. All four are indistinguishable.

`event_not_found` (`retry: do_not_retry`) — No such event in your workspace, or it has not finished processing and is not addressable yet.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X DELETE "$TRAYO_API_URL/v1/events/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.delete(f"{BASE}/v1/events/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## People

Contacts at accounts you track — and the lookups that find their emails and phones, with provenance.

### GET /v1/people

`listPeople` — List people

**Required permissions:** `people:read`.

A page of people, most recently created first. PAGINATES: follow `nextCursor` until `hasMore` is false.

**Why each person matters is behind `?expand=`.** Send `?expand=stakeholderReport,events` to add both `stakeholderReport` — the generated report on the person, the same one the Trayo app shows — and `events`, the events they are attached to with the per-event reasoning. Without the parameter neither KEY is present (absent, not `null` and not `[]`), because a page of 50 reports is a large response for a call that only wanted names. `GET /v1/people/{id}` always includes both.

**Parameters**

- `accountId` (query, uuid, optional) — Restrict to people linked to this account. An `accountId` that does not exist, or belongs to another workspace, is not an error — it simply matches no rows.
- `q` (query, string, optional) — Case-insensitive substring match against `fullName` only — does not search `title`, `email`, or anything else.
- `hasEmail` (query, string, optional) — Send the literal string `true` or `false`. `true` returns only people whose `email` comes back non-null, `false` only those whose does not. Omit the parameter to return people regardless. Any other value is rejected rather than guessed at. It filters on the SAME `email` the response publishes, from either source: an address you wrote (`emailSource: "manual"`) or one a lookup found and still considers deliverable (`emailSource: "enrichment"`). So `POST /v1/people/{id}/enrich` finding an address moves that person from `?hasEmail=false` to `?hasEmail=true`. A found address that later bounced, or a lookup that is still running, found nothing, or went stale, leaves `email` null and the person on the `false` side — the two sides always partition the workspace, never overlap.
- `isRepVisible` (query, string, optional) — Filter on the derived `isRepVisible` flag. Same parsing as `hasEmail`, but NOT the same idea — the two sit next to each other and are unrelated. **Omit it to list both values.** `?isRepVisible=true` lists people somebody added or Trayo attached to an event. `?isRepVisible=false` lists profiles Trayo collected while searching but never surfaced. Adding one of those with `POST /v1/people` moves it to the `true` side. See the field's own description on `PersonDto`.
- `updatedSince` (query, string, optional) — Send a plain date — `2026-06-07`, read as midnight UTC — or a full ISO-8601 timestamp — `2026-06-07T14:30:00Z` — when you need the time of day. **Relative words are not accepted:** `yesterday`, `last-quarter`, `7d` and anything else that has to be resolved against "now" is rejected with a 400 (`invalid_date`). Compute the date yourself and send it. Returns people with `updatedAt >=` this value. A contact lookup counts as an update: when a lookup finishes for a person — whether it found an `email` or `phone`, found nothing, or a previously found address bounced — that person's `updatedAt` moves. A sweep that polls this parameter on a schedule therefore sees contact details that arrived since its last run, not only edits made through the API.
- `expand` (query, string, optional) — Send `expand=stakeholderReport,events` to add the `stakeholderReport` and `events` fields to every item in `data`. Despite the comma-separated syntax, expansion is ALL-OR-NOTHING: any non-empty value — `events` alone, even `expand=bogus` — expands both fields together. There is no way to request only one. Omit the parameter entirely for the compact form, which omits both KEYS rather than sending them empty. Both are heavy — a whole markdown report and a join across every event a person is attached to — which is why they are behind this parameter here and unconditional on `GET /v1/people/{id}`.
- `residenceCity` (query, string, optional) — Only people who say on their LinkedIn profile that they are in this city, e.g. `San Diego`. Case-insensitive, whole name. A person whose profile names only a metro area (`Greater San Diego Area`) also matches when that text contains the city. This is where the person is, not where their account is headquartered; see the person's `location`.
- `residenceState` (query, string, optional) — Only people whose stated LinkedIn location is in this state, province or region, e.g. `California`. Case-insensitive, whole name.
- `residenceCountry` (query, string, optional) — Only people whose stated LinkedIn location is in this country: an ISO-2 code (`US`) or a country name (`United States`), any case. A name is read as its ISO-2 code.
- `cursor` (query, string, optional) — Opaque pagination cursor from a previous page's `nextCursor`. Omit for the first page. Do not construct or parse this value — it is a base64url-encoded implementation detail and may change shape without notice.
- `limit` (query, integer, optional) — Page size, default 25. Values above 100 are silently CAPPED to 100, not rejected. Note the deliberate contrast with the two other bounds in this API: `POST /v1/find`'s `limit` is rejected above 100 (and a sentence search returns at most 50), and a `/v1/…/batch` array of more than 200 items is an ERROR rather than a clamp — silently dropping rows would lose records the caller believes it created. A `limit` that is zero, negative or not an integer is rejected here too; only the upper bound clamps.

**Responses**

- `200` → PersonListDto: 
  - `data` (object[], required) — People, most recently created first.
    - `id` (uuid, required)
    - `accountId` (uuid, required)
    - `fullName` (string, required)
    - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
    - `phone` (string | null, 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 | null, required)
    - `linkedinUrl` (string | null, 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 | null, 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.
    - `profileImageUrl` (string | null, 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 | null, 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` (date-time | null, required)
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
    - `stakeholderReport` (string | null, optional) — 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[], optional) — 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.
  - `hasMore` (boolean, required) — Another page exists. When `false` you have reached the end — stop requesting pages.
  - `nextCursor` (string | null, required) — Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.

`invalid_date` (`retry: fix_input`) — A date filter (`updatedSince`, `since`, `detectedSince`) did not parse. Send a plain date (`2026-06-07`, read as midnight UTC) or a full ISO-8601 timestamp (`2026-06-07T14:30:00Z`). Relative words such as `yesterday` or `last-quarter` are not accepted. `param` names the field.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/people" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/people", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/people

`createPerson` — Create a person

**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** (`application/json`)

- `fullName` (string, required)
- `title` (string, optional) — 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, optional) — 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` (email, optional)
- `linkedinUsername` (string, optional)
- `accountId` (uuid, optional) — 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, optional) — 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, optional) — 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, optional) — 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, optional) — Accepted and IGNORED — declared only so a `POST /v1/find` contacts result can be posted here verbatim. Never persisted or echoed back.
- `linkedinUrl` (string, optional) — 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, optional) — Accepted and IGNORED — declared only so a `POST /v1/find` contacts result can be posted here verbatim. Never persisted or echoed back.

**Responses**

- `200` → PersonDto: Existing person updated.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `fullName` (string, required)
  - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
  - `phone` (string | null, 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 | null, required)
  - `linkedinUrl` (string | null, 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 | null, 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 | null, required) — City, e.g. `San Diego`. Null when the profile names only a region or metro area.
    - `state` (string | null, required) — State, province or region, e.g. `California`.
    - `country` (string | null, required) — ISO 3166-1 alpha-2 country code, e.g. `US`.
    - `raw` (string | null, 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 | null, 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 | null, 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` (date-time | null, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
  - `stakeholderReport` (string | null, optional) — 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[], optional) — 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` (uuid, required) — Read the full event with `GET /v1/events/{id}`.
    - `title` (string, required)
    - `eventDate` (date-time, required)
    - `signalType` (string, required)
    - `state` (one of: "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.
- `201` → PersonDto: Person created.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `fullName` (string, required)
  - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
  - `phone` (string | null, 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 | null, required)
  - `linkedinUrl` (string | null, 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 | null, 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 | null, required) — City, e.g. `San Diego`. Null when the profile names only a region or metro area.
    - `state` (string | null, required) — State, province or region, e.g. `California`.
    - `country` (string | null, required) — ISO 3166-1 alpha-2 country code, e.g. `US`.
    - `raw` (string | null, 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 | null, 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 | null, 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` (date-time | null, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
  - `stakeholderReport` (string | null, optional) — 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[], optional) — 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` (uuid, required) — Read the full event with `GET /v1/events/{id}`.
    - `title` (string, required)
    - `eventDate` (date-time, required)
    - `signalType` (string, required)
    - `state` (one of: "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.
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/people" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fullName":"<fullName>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/people", headers=HEADERS, json={"fullName":"<fullName>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/people/batch

`batchCreatePeople` — Batch create people

**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** (`application/json`)

- `people` (string[], 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.

**Responses**

- `200` → BatchCreatePeopleResultDto: 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.
  - `created` (object[], required) — The rows that were created, in submission order.
    - `id` (uuid, required)
    - `accountId` (uuid, required)
    - `fullName` (string, required)
    - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
    - `phone` (string | null, 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 | null, required)
    - `linkedinUrl` (string | null, 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 | null, 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.
    - `profileImageUrl` (string | null, 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 | null, 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` (date-time | null, required)
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
    - `stakeholderReport` (string | null, optional) — 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[], optional) — 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.
  - `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` (one of: "duplicate" | "identity_conflict", optional) — 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` (uuid, optional) — 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` (uuid, required) — The id of the created row this entry describes.
    - `codes` (one of: "no_account" | "no_identity"[], 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` (uuid, required)
    - `accountId` (uuid, required)
    - `fullName` (string, required)
    - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
    - `phone` (string | null, 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 | null, required)
    - `linkedinUrl` (string | null, 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 | null, 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.
    - `profileImageUrl` (string | null, 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 | null, 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` (date-time | null, required)
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
    - `stakeholderReport` (string | null, optional) — 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[], optional) — 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.
    - `index` (integer, required) — 0-based position of the row in the submitted array.
- `400` → ErrorDto: 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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/people/batch" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"people":["<people>"]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/people/batch", headers=HEADERS, json={"people":["<people>"]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/people/{id}

`getPerson` — Get a person

**Required permissions:** `people:read`.

Always returns `stakeholderReport` and `events` — unlike the list route, there is no `expand` parameter here because it is not needed.

Those two are where "why does this person matter?" is answered. `stakeholderReport` is the generated report on the person, the same one the Trayo app shows, and is `null` until stakeholder research has run for them. `events` lists what they were attached to and, per event, why they were named on it.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → PersonDto: The person, always expanded.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `fullName` (string, required)
  - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
  - `phone` (string | null, 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 | null, required)
  - `linkedinUrl` (string | null, 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 | null, 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 | null, required) — City, e.g. `San Diego`. Null when the profile names only a region or metro area.
    - `state` (string | null, required) — State, province or region, e.g. `California`.
    - `country` (string | null, required) — ISO 3166-1 alpha-2 country code, e.g. `US`.
    - `raw` (string | null, 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 | null, 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 | null, 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` (date-time | null, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
  - `stakeholderReport` (string | null, optional) — 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[], optional) — 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` (uuid, required) — Read the full event with `GET /v1/events/{id}`.
    - `title` (string, required)
    - `eventDate` (date-time, required)
    - `signalType` (string, required)
    - `state` (one of: "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.
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: No such person, OR it belongs to another workspace — the two are indistinguishable on purpose.

`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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl "$TRAYO_API_URL/v1/people/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.get(f"{BASE}/v1/people/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### PATCH /v1/people/{id}

`updatePerson` — Update a person

**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.

**Parameters**

- `id` (path, uuid, required)

**Request body** (`application/json`)

- `fullName` (string, optional)
- `title` (string, optional) — 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 | null, optional) — 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` (email, optional)
- `linkedinUsername` (string, optional)

**Responses**

- `200` → PersonDto: The person after the update.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `fullName` (string, required)
  - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
  - `phone` (string | null, 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 | null, required)
  - `linkedinUrl` (string | null, 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 | null, 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 | null, required) — City, e.g. `San Diego`. Null when the profile names only a region or metro area.
    - `state` (string | null, required) — State, province or region, e.g. `California`.
    - `country` (string | null, required) — ISO 3166-1 alpha-2 country code, e.g. `US`.
    - `raw` (string | null, 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 | null, 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 | null, 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` (date-time | null, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
  - `stakeholderReport` (string | null, optional) — 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[], optional) — 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` (uuid, required) — Read the full event with `GET /v1/events/{id}`.
    - `title` (string, required)
    - `eventDate` (date-time, required)
    - `signalType` (string, required)
    - `state` (one of: "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.
- `400` → ErrorDto: 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` → ErrorDto: 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` → string: 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` → ErrorDto: `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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X PATCH "$TRAYO_API_URL/v1/people/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fullName":"<fullName>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.patch(f"{BASE}/v1/people/{id}", headers=HEADERS, json={"fullName":"<fullName>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### DELETE /v1/people/{id}

`deletePerson` — Delete a person

**Required permissions:** `people:delete`.

Soft delete. Returns 200, not 204, with the person as it was — nothing in the response body distinguishes it from a normal GET, and there is no `deletedAt` field in this API's Person shape. The proof of deletion is behavioral: this id now 404s on every route and no longer appears in `GET /v1/people`.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → PersonDto: The person as it existed immediately before deletion.
  - `id` (uuid, required)
  - `accountId` (uuid, required)
  - `fullName` (string, required)
  - `title` (string | null, 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 | null, 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 | null, 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` (one of: "manual" | "enrichment" | null, required) — `manual` = written by a caller or in the app; `enrichment` = found by a lookup.
  - `phone` (string | null, 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 | null, required)
  - `linkedinUrl` (string | null, 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 | null, 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 | null, required) — City, e.g. `San Diego`. Null when the profile names only a region or metro area.
    - `state` (string | null, required) — State, province or region, e.g. `California`.
    - `country` (string | null, required) — ISO 3166-1 alpha-2 country code, e.g. `US`.
    - `raw` (string | null, 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 | null, 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 | null, 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` (date-time | null, required)
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
  - `stakeholderReport` (string | null, optional) — 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[], optional) — 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` (uuid, required) — Read the full event with `GET /v1/events/{id}`.
    - `title` (string, required)
    - `eventDate` (date-time, required)
    - `signalType` (string, required)
    - `state` (one of: "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.
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X DELETE "$TRAYO_API_URL/v1/people/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.delete(f"{BASE}/v1/people/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/people/enrich/batch

`batchEnrichPeople` — Look up emails and phones for many people

**Required permissions:** `body.fields` includes `email`: `people:enrich_email`; `body.fields` includes `phone`: `people:enrich_phone`. All scopes for the selected fields are required. Omitting `body.fields` uses `["email","phone"]` and requires `people:enrich_email` and `people:enrich_phone`. Malformed values: no additional scopes; unknown fields add no scopes. Request validation still applies.

Start lookups for up to 200 people in one call, with the same rules as the single route applied per person. Partial success is the contract: a person who exists gets an outcome in `accepted` even if every field was skipped; an id that is unknown, another workspace's, or a duplicate earlier in the batch lands in `skipped` by index.

**This route always answers `200`, never `202` or `402`.** A workspace allowance stop — the plan lookup limit, the workspace quota, or the daily usage limit — is reported per person rather than raised: people reached before the stop are looked up and charged, and the person that hit it and every person after it are reported in `skipped` with the limit as the reason. Nothing after the limit is partially run. `stoppedBy` says so as data: which allowance, how many people were processed and how many were not looked up.

**The batch has a 120s budget for starting lookups.** The whole batch starts together and normally takes a few seconds. The budget is checked once, before anything is reserved: if it has run out, the first person who needed a lookup and every person after are reported in `skipped` with a reason saying the call ran out of time. Nothing was started for them and nothing was spent, so send those ids again in smaller batches. Retain this request's `personIds` array and map `skipped[].index` back to it. Only re-send time-skipped rows this way: other rows in `skipped` may be duplicates, invalid ids, or allowance refusals that need a different remedy. Poll already-started lookups with `GET /v1/people/{id}/enrich`. If a retry makes no progress, stop and keep the unresolved ids for later.

**A lookup is explicit, asynchronous, idempotent, and governed by your plan.** Nothing is looked up on create or on read; this call is the only trigger, and it reports what it started and what it SKIPPED — a field already in progress, already found, blocked by a written email, or inside its retry cooldown is skipped with the reason, never an error — so sending it twice never double-runs or is charged twice. Poll `GET /v1/people/{id}/enrich` (or read the person) for the result; a lookup takes seconds to a minute.

**Provenance is kept.** A found value carries `source`, `status` and `foundAt`; a value you wrote is `source: "manual"` and is never overwritten by a found one — the email lookup is not even attempted while one is present.

**Plan limits are checked BEFORE anything runs.** Each lookup that starts counts once against a lifetime plan limit. Every response carries `quota` — `limit`, `used` and `remaining` per field, as they stand after the call, the same numbers `GET /v1/whoami` reports — so size each request by `quota.<field>.remaining` rather than learning the limit from a refusal.

**Request body** (`application/json`)

- `personIds` (uuid[], required) — 1 to 200 person ids. Over the cap is an error, not a clamp.
- `fields` (one of: "email" | "phone"[], optional) — Which values to look up. Omit for both. A field that cannot or need not run is reported in `skipped`, never an error.

**Responses**

- `200` → BatchEnrichResultDto: The batch was processed. Check `skipped` — a `200` does not mean a lookup started for every person.
  - `accepted` (object[], required) — One outcome per person that exists in your workspace, in submission order.
    - `personId` (uuid, required)
    - `started` (one of: "email" | "phone"[], required) — The lookups that were started by THIS call. Poll `GET /v1/people/{id}/enrich` for them, or `trayo_get_contacts` on the MCP server.
    - `skipped` (object[], required) — The lookups this call did NOT start, and why. Never an error.
    - `contact` (object, required) — The state immediately after those lookups started.
  - `skipped` (object[], required) — Person ids this call could not act on at all: unknown or another workspace's, a duplicate earlier in the batch, a plan limit that stopped the batch, or the time limit the call reached before it got to them — the reason says which. Rows after a limit are skipped, not partially run; rows the call ran out of time for were never started and never charged, so send them again.
    - `index` (integer, required) — 0-based position in the submitted `personIds`.
    - `reason` (string, required)
  - `stoppedBy` (object, optional) — Present when a workspace allowance ended the batch before every person was evaluated. Branch on this rather than on `skipped[].reason` prose.
    - `reason` (one of: "lookup_limit_reached" | "quota_daily_limit" | "quota_exceeded", required) — Which workspace allowance ended the batch. `lookup_limit_reached`: the plan's lifetime email/phone lookup allowance is used up — it does not reset, the plan has to change. `quota_exceeded`: the workspace quota — raise it or wait for it to renew. `quota_daily_limit`: the rolling 24-hour usage limit — wait, then retry.
    - `retry` (one of: "retry_later" | "do_not_retry", required) — Whether waiting alone clears this stop — the same `retry` an error envelope for this code carries. `do_not_retry` means re-sending the skipped people changes nothing until the allowance itself changes.
    - `processed` (integer, required) — How many submitted people this call evaluated before the stop (they are in `accepted`).
    - `skippedOther` (integer, required) — How many submitted people before the stop were skipped for their own reason — an unknown id or a duplicate. They are in `skipped`; fix and re-send them. `processed + skippedOther + notLookedUp` is the number of people submitted.
    - `notLookedUp` (integer, required) — How many submitted people the allowance left unevaluated — the person it refused plus everyone after them. They are in `skipped`; nothing was started or charged for them.
    - `message` (string, required) — The refusal, in words, with what to do next.
  - `quota` (object, optional) — Email and phone lookup usage and any active allowances AFTER this call, counted separately. `limit` and `remaining` are `null` when no lookup cap applies. If `remaining` is a number, size your next request by it: people past that allowance are skipped. Omitted only when the allowance could not be read; `GET /v1/whoami` (MCP: trayo_whoami) reports the same numbers.
    - `email` (object, required)
    - `phone` (object, required)
- `400` → ErrorDto: The ENVELOPE was wrong: `personIds` missing, empty, over 200, not UUIDs, or an unknown `fields` entry. A problem with one PERSON never produces this status — it is reported in the `200` response's `skipped`.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/people/enrich/batch" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"personIds":["11111111-1111-4111-8111-111111111111"]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/people/enrich/batch", headers=HEADERS, json={"personIds":["11111111-1111-4111-8111-111111111111"]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/people/{id}/enrich

`getPersonContact` — Read the contact lookup state

**Required permissions:** `people:read`.

The full contact picture for a person: the email and phone to use, where each came from, and the state of the lookup behind each. This is the poll target after `POST /v1/people/{id}/enrich`: a field reads `in_progress` until its lookup finishes, then `found` or `not_found` (with `retryAvailableAt`). Free; nothing is started by reading.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → ContactStatusDto: 
  - `personId` (uuid, required)
  - `email` (object, required)
    - `value` (string | null, required) — The email to use: a manual value if one was written, else the found one.
    - `source` (one of: "manual" | "enrichment" | null, required) — `manual` = written through this API or the app; `enrichment` = found.
    - `status` (one of: "verified" | "unconfirmed" | "bounced" | "stale" | null, required) — State of a FOUND email, and the field to act on. `verified` = a finder returned it and nothing suggests it belongs to someone else. `unconfirmed` = a finder returned it, but `identityConfidence` is `conflict`: the address disagrees with this person's name or company, or another person in the workspace holds the same address. The value is still given; have a human check it before sending. `bounced` after a send bounced (the value is withheld); `stale` when the product no longer trusts it. Neither `verified` nor `unconfirmed` is a mailbox probe. Null for a manual value and when nothing was found.
    - `identityConfidence` (one of: "match" | "conflict" | "unknown" | null, required) — Whether a FOUND email looks like THIS person's: `match` when its local-part agrees with their name or its domain with their account's website; `conflict` when either disagrees (a catch-all or a wrong-account hit) or when another person in the workspace holds the same address, and `status` then reads `unconfirmed`; `unknown` when there is nothing to compare against (no name, no account website, a free-mail domain, a sibling domain of the account's, or an account whose website matches none of its people's addresses). Null for a manual value and when nothing was found.
    - `foundAt` (date-time | null, required)
    - `lookup` (object, required)
  - `phone` (object, required)
    - `value` (string | null, required) — The found phone number, or null.
    - `foundAt` (date-time | null, required)
    - `lookup` (object, required)
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl "$TRAYO_API_URL/v1/people/{id}/enrich" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.get(f"{BASE}/v1/people/{id}/enrich", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/people/{id}/enrich

`enrichPerson` — Look up an email and/or phone

**Required permissions:** `body.fields` includes `email`: `people:enrich_email`; `body.fields` includes `phone`: `people:enrich_phone`. All scopes for the selected fields are required. Omitting `body.fields` uses `["email","phone"]` and requires `people:enrich_email` and `people:enrich_phone`. Malformed values: no additional scopes; unknown fields add no scopes. Request validation still applies.

Find this person's email and/or phone the same way the Trayo app's own "Find email" does. Omit `fields` for both.

**It returns `202` at once** with what it started and what it skipped. Over the plan limit, or on a plan with no such lookups, the call is `402 lookup_limit_reached`: nothing was started and nothing was charged.

**A lookup is explicit, asynchronous, idempotent, and governed by your plan.** Nothing is looked up on create or on read; this call is the only trigger, and it reports what it started and what it SKIPPED — a field already in progress, already found, blocked by a written email, or inside its retry cooldown is skipped with the reason, never an error — so sending it twice never double-runs or is charged twice. Poll `GET /v1/people/{id}/enrich` (or read the person) for the result; a lookup takes seconds to a minute.

**Provenance is kept.** A found value carries `source`, `status` and `foundAt`; a value you wrote is `source: "manual"` and is never overwritten by a found one — the email lookup is not even attempted while one is present.

**Plan limits are checked BEFORE anything runs.** Each lookup that starts counts once against a lifetime plan limit. Every response carries `quota` — `limit`, `used` and `remaining` per field, as they stand after the call, the same numbers `GET /v1/whoami` reports — so size each request by `quota.<field>.remaining` rather than learning the limit from a refusal.

**Parameters**

- `id` (path, uuid, required)

**Request body** (`application/json`)

- `fields` (one of: "email" | "phone"[], optional) — Which values to look up. Omit for both. A field that cannot or need not run is reported in `skipped`, never an error.

**Responses**

- `202` → EnrichPersonResultDto: Accepted. `started` lists the lookups this call started; `skipped` lists the ones it did not and why; `contact` is the state right now; `quota` is the lookup allowance left after this call.
  - `personId` (uuid, required)
  - `started` (one of: "email" | "phone"[], required) — The lookups that were started by THIS call. Poll `GET /v1/people/{id}/enrich` for them, or `trayo_get_contacts` on the MCP server.
  - `skipped` (object[], required) — The lookups this call did NOT start, and why. Never an error.
    - `field` (one of: "email" | "phone", required)
    - `reason` (one of: "manual_present" | "already_found" | "in_progress" | "retry_too_soon" | "start_failed", required) — `manual_present`: you wrote an email, it wins, nothing to find. `already_found`: a value is already on the record. `in_progress`: a lookup is already running. `retry_too_soon`: the last run missed and the cooldown has not passed — `retryAvailableAt` says when. `start_failed`: this field passed every check but could not be started. Nothing is running for it, and re-sending is safe and is not charged twice. (When NO field could be started the call is a `503 lookup_start_failed` instead.)
    - `retryAvailableAt` (date-time | null, optional)
  - `contact` (object, required) — The state immediately after those lookups started.
    - `personId` (uuid, required)
    - `email` (object, required)
    - `phone` (object, required)
  - `quota` (object, optional) — Email and phone lookup usage and any active allowances AFTER this call, counted separately. `limit` and `remaining` are `null` when no lookup cap applies. If `remaining` is a number, size your next request by it: people past that allowance are skipped. Omitted only when the allowance could not be read; `GET /v1/whoami` (MCP: trayo_whoami) reports the same numbers.
    - `email` (object, required)
    - `phone` (object, required)
- `400` → ErrorDto: `id` is not a UUID, or `fields` names something other than `email`/`phone`.

`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` → ErrorDto: 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` → ErrorDto: `lookup_limit_reached` (`retry: do_not_retry`) — This workspace cannot run this contact lookup: the plan's email or phone lookup limit is reached, or the plan does not include that kind of lookup. `message` says which and quotes the limit. Retrying never helps and neither does changing the request — the plan has to change. Nothing was started and nothing was charged.
- `403` → string: 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` → ErrorDto: `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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `lookup_start_failed` (`retry: retry_later`) — The contact lookup passed every check but could not be started. No lookup is running for it, and a retry of the same request is not charged twice. Send it again shortly.

**curl**

```shell
# Replace {id} with the real value.
curl -X POST "$TRAYO_API_URL/v1/people/{id}/enrich" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fields":["email"]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.post(f"{BASE}/v1/people/{id}/enrich", headers=HEADERS, json={"fields":["email"]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/people/{id}/ask

`askPerson` — Ask a question about a person

**Required permissions:** `people:read`.

**Derived-context capability: people:read includes derived briefing context.** For this operation, that scope permits the person record, stored contacts, notes, list membership, synthesis and stakeholder reports, the linked account and its stored research, attached events and rationale, sequences and outcomes, sent emails, composed messages, the LinkedIn profile and posts, and workspace solution and stakeholder criteria. Answers may include facts from this derived context. No additional source scopes are required here, including when the owner role grants only people:read. The key and its owner's current workspace role must both allow people:read. Losing unrelated owner permissions does not block this operation. GET /v1/whoami reports the key's current effective scopes in key.effectiveScopes. Direct account and workspace reads still require accounts:read, and direct event reads events:read. This capability grants no writes or new research. Sources remain limited to the existing person dossier and workspace context; the receipt describes which records informed the answer, subject to existing limits.

Ask anything about one person and get an answer grounded in everything Trayo holds on them. **Synchronous and read-only**: the answer comes back in one response, typically in 5–15 seconds (the server gives up at 120 seconds by default with `503 person_ask_failed`, so set your client timeout above that and retry once on `retry_later`). The answer is not saved. With usage enforcement on: Each answer returned draws once on the workspace's usage quota; a refused (`404`) or failed (`503`) call does not. Over the quota is `402 quota_exceeded`; over the workspace's rolling 24-hour usage limit is `429 quota_daily_limit`.

 With it off, usage has no quotas. **What goes into the answer.** Two kinds of source, rendered into one dossier with sane limits: the person's **Trayo record** — the contact details on record, the account and its research, the signals (events) the person is attached to and Trayo's reason for each, every sequence composed for them with each step's send/open/reply outcome, emails sent, messages composed, lists, any stored synthesis or stakeholder report, and the `note` you wrote — plus the **LinkedIn profile** from Trayo's own data: headline, about, work history with dates, and the person's recent posts.

**`intent` steers the answer.** Say what you will do with it — "qualifying whether to sequence them, I need a verdict and the reason", "drafting a first email, I need hooks I can cite", "briefing a rep before a call" — and the focus, depth and form follow. Without it you get a direct, concise answer.

**No confidence score is returned — read `sources` to see what the answer is grounded in.** It is a receipt of the records used: how many signals, whether any outreach exists, whether the LinkedIn profile was found. `sources.linkedin.status` is `not_linked` when the person has no LinkedIn identity in Trayo (set `linkedinUsername` with `PATCH /v1/people/{id}` and ask again), `not_found` when Trayo has no record for that profile, and `unavailable` when that data could not be reached this time — the answer then rests on the Trayo record alone. `gaps` names what was missing.

**Grounded, not researched.** Trayo answers from these records only: no web search, no guessing. A fact that is not on record is named as unknown. To put more on record first: `POST /v1/people/{id}/enrich` finds an email or phone, and `POST /v1/discoveries` puts events and the people attached to them on the account's record.

**Parameters**

- `id` (path, uuid, required)

**Request body** (`application/json`)

- `question` (string, required) — The question, in plain prose. Anything a rep might ask a colleague who knows the person: "Is she the right buyer for us?", "What have we sent him and did he respond?", "How long has she been in this role, and where was she before?", "Give me three talking points for a first call."
- `intent` (string, optional) — What you will do with the answer — one or two sentences. It steers focus, depth and form. "Qualifying whether to add them to a sequence; I need a yes/no with the reason" gets a verdict; "Drafting a first-touch email; I need specific hooks I can cite" gets hooks with their sources; "Briefing a rep before a call" gets a short brief. Omit it for a direct, concise answer.

**Responses**

- `200` → AskPersonResponseDto: The answer, what was missing from the record (`gaps`), and a receipt of the records used (`sources`).
  - `personId` (uuid, required) — The person this answer is about — the id you sent in the path.
  - `question` (string, required) — Your question, as answered.
  - `intent` (string | null, required) — Your intent, as applied; null when you sent none.
  - `answer` (string, required) — The answer, in Markdown. Grounded in the dossier only: a fact the dossier does not hold is named as unknown, never guessed.
  - `gaps` (string[], required) — What was missing that would have made the answer better, up to five items — e.g. "no LinkedIn profile linked", "no outreach on record". Empty when nothing material was missing.
  - `sources` (object, required) — A receipt of what the answer was grounded in. Read it before trusting an assured-sounding answer, and to decide what to look up or link before asking again.
    - `trayo` (object, required) — Which workspace records went into the prompt.
    - `linkedin` (object, required) — Whether the LinkedIn profile from Trayo's own data went into the prompt, and how much of it.
- `400` → ErrorDto: `id` is not a UUID; `question` missing, blank, or over 2000 characters; `intent` blank or over 2000 characters; or an unknown field. `param` names it.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: `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` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: 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.
- `503` → ErrorDto: `person_ask_not_configured` (`retry: do_not_retry`) — Questions about a person are unavailable. Retrying will not help. Contact support. To read the saved person record, use `GET /v1/people/{id}`.

`person_ask_failed` (`retry: retry_later`) — The answer failed, came back unusable, or ran past the time budget. Nothing was written. Send the same request again shortly. A LinkedIn-profile failure is NOT this error: the answer proceeds on the Trayo record and `sources.linkedin.status` says `unavailable`.

**curl**

```shell
# Replace {id} with the real value.
curl -X POST "$TRAYO_API_URL/v1/people/{id}/ask" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question":"<question>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.post(f"{BASE}/v1/people/{id}/ask", headers=HEADERS, json={"question":"<question>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/job-changes/search

`searchJobChanges` — Find recent company moves

**Required permissions:** `people:read`.

Stateless search over our people data. Send `source` (the company left), `destination` (the company joined), or both. Or search without a company by `title`, the new role (same terms as `/v1/find` `filters.title`), narrowed by `destinationFilters` (`hq.countries`, `headcount` of the company joined); those two go only with a title search. Inclusive `startedSince` and exclusive `startedBefore` (the month the new role started, month precision) narrows any of these searches, with or without a company. One of source, destination or title is required. Returns moves observed in the last 90 days and writes nothing. One row per person per destination — we hold a row per source record, and those are collapsed; a title search returns one row per person. `limit` sets the page size: 25 by default, 100 at most, capped rather than rejected. `hasMore` says more matches may remain behind this page; send `nextCursor` back as `cursor`, verbatim and with the same search, to walk them. The walk resumes where the page stopped, so a later page neither repeats nor skips what an earlier one returned. `hasMore` reads the row count before duplicate records are collapsed, so a page can come back short and still be `true` — follow `hasMore` and `nextCursor`, never the row count. A source-only search may read a fixed sample rather than an ordered walk and cannot be paged; indexed source-only searches can page. Sending `cursor` on a sampled source-only search is rejected, `nextCursor` is null, and `hasMore: true` there means narrow the search. Report truncation for the requested period. Each row carries `detectedAt` (when we saw the move), `startedAt` (when the role began, to the month, often null) and `startedAtPrecision` (`month` or `year`). `detectedSince` filters detection recency: it keeps only moves with `detectedAt >=` the value you send — the DETECTION date, not the start date; `startedSince` filters the start, and both can be sent. It only narrows the 90-day window, starting at midnight UTC 90 days ago: an earlier value is clamped to that start and a future value matches nothing. Destination searches already return the newest moves first: narrowing the window does not recover omitted matches; it returns the same rows or a subset. This is a recency filter, not pagination. `seniority` is the second filter: it keeps only moves INTO a role at that level or above, read from the title of the role they joined — not the one they left, and not a title list you supply. The four seniority levels widen in order: `cxo` (the C-suite), `vp` (adds VP, SVP, EVP, Vice President and President), `director` (adds Director), `head` (adds Partner, Principal and Head of). With `destination`, it WIDENS the search rather than trimming the page — the search reads further through recent moves looking for matches instead of filtering the slice it would have read without it — and that wider read is still bounded, so a short or empty answer does not prove nobody senior joined. Source-only searches that report `sampled: true` filter the same fixed sample; they do not read further. Title matching is heuristic and excludes assistant/support roles. `sampled: true` means a source-only search read a small fixed sample of recent moves; an empty answer does not mean nobody left, and retrying gives the same answer while the underlying data is unchanged. A source-only search with `sampled: false` walked the newest moves out of that company instead of sampling them. With `destination`, `sampled` is false: the search reads a bounded slice of the newest moves into that company before filtering by source. `hasMore: false` does not prove completeness with either value of `sampled`. A title search reads the title index up to a cap and says `sampled: true` when the title is held by more people than that; each page reads a bounded number of moves, so it can come back short, even empty, with `hasMore: true` — follow the cursor. A company search with `startedSince` or `startedBefore` checks the start after reading each move, so it also reads a bounded number per page and pages the same way. Every row is a move between two companies; promotions and same-company title changes never appear here. For promotions, and for checks you repeat on a company over time, use the workspace `job_change` signal (`create_signal`, then `run_discovery`). That signal is not a departure search and does not provide a complete list of who left. A workspace can run only a few job-change searches at once. A search sent while that many are running comes back as `503 data_search_busy` before anything is read: wait `details.retryAfterSeconds`, send it again, and send fewer at the same time.

**Request body** (`application/json`)

- `source` (object, optional) — The company the person left.
  - `companyId` (integer | string, optional) — Trayo's own id for the company, as a number or numeric string: the `companyId` a post, post-engager or person-research result carries, or the `id` of a company find result. Not a workspace account UUID. When sent, it is the key: the company is read by this id and nothing else is tried, so an id Trayo does not hold is `company_not_found`. A `name`, `website` or `linkedinUrl` sent beside it is not used.
  - `accountId` (uuid, optional) — An account in your workspace, on its own. Its existing match is used when it has one; otherwise the account is resolved from its website, LinkedIn handle and name exactly as the app would, so an account we have not yet matched still works when the company is in our data.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.
- `destination` (object, optional) — The company the person joined.
  - `companyId` (integer | string, optional) — Trayo's own id for the company, as a number or numeric string: the `companyId` a post, post-engager or person-research result carries, or the `id` of a company find result. Not a workspace account UUID. When sent, it is the key: the company is read by this id and nothing else is tried, so an id Trayo does not hold is `company_not_found`. A `name`, `website` or `linkedinUrl` sent beside it is not used.
  - `accountId` (uuid, optional) — An account in your workspace, on its own. Its existing match is used when it has one; otherwise the account is resolved from its website, LinkedIn handle and name exactly as the app would, so an account we have not yet matched still works when the company is in our data.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.
- `detectedSince` (string, optional) — Only moves with `detectedAt >=` this value. It filters `detectedAt` — when Trayo first observed the new role — and NOT `startedAt`, the date the role began; the two can be months apart. To filter on `startedAt`, send `startedSince`; both can be sent, and both apply. The 90-day window starts at midnight UTC 90 days ago; `detectedSince` can only narrow it. An earlier value is clamped to that start, and a future value matches nothing. Destination searches already return the newest moves first: narrowing the window does not recover omitted matches; it returns the same rows or a subset. This is a recency filter, not pagination. Report `hasMore` truncation for the requested period. Send a plain date — `2026-06-07`, read as midnight UTC — or a full ISO-8601 timestamp — `2026-06-07T14:30:00Z` — when you need the time of day. **Relative words are not accepted:** `yesterday`, `last-quarter`, `7d` and anything else that has to be resolved against "now" is rejected with a 400 (`invalid_date`). Compute the date yourself and send it.
- `seniority` (one of: "cxo" | "vp" | "director" | "head", optional) — Keep only moves INTO a role at this level or above. It reads the title of the role they joined — not the one they left, and not a title list you supply. The four seniority levels widen in order: `cxo` is the C-suite (CEO, CFO, CTO, COO, CMO, CIO, CISO, CRO, CPO and other Chief … Officer titles); `vp` adds every vice-president form (VP, SVP, EVP, Vice President) and President; `director` adds Director; `head` adds Partner, Principal and Head of. With `destination`, sending it WIDENS the search rather than trimming the page: the search reads further through recent moves looking for matches, instead of filtering the same slice it would have read without it. That wider read is still bounded, so a short or empty answer does not prove nobody senior joined, and `hasMore: false` still does not prove completeness. Source-only searches that report `sampled: true` filter the same fixed sample; they do not read further. Matching is heuristic and excludes assistant/support roles. Titles are matched on their words, so a company that names its leaders unusually can come back empty at a level where people do hold the job.
- `limit` (integer, optional) — How many moves to return in this page. Defaults to 25. Values above 100 are capped at 100, not rejected — the same ceiling the other paginating routes in this API use.
- `cursor` (string, optional) — The `nextCursor` from the previous page, sent back verbatim with the SAME search. Omit it for the first page. It resumes the walk where the last page stopped, so a later page neither repeats nor skips what an earlier one returned. It is opaque: do not build or read one. Destination and title searches accept cursors; an indexed source-only search does too. A source-only search may read a fixed sample rather than an ordered walk: when sampled is true, it cannot resume and sending a cursor is rejected. A `title` search pages the same way a destination search does. Changing the search between pages invalidates the cursor.
- `title` (object, optional) — Search WITHOUT a company: moves into a role whose title matches, at any company. Same syntax and matching as `/v1/find` `filters.title`: `any`, `all` and `exclude` terms, matched on the words of the title, and an acronym matches only itself — send `["CISO", "Chief Information Security Officer"]`, not one form. Needs `any` or `all`. Cannot be combined with `source` or `destination`; for one company, send it as `destination` and use `seniority`. Reads the moves detected in the same 90-day window, newest first. The title index is read up to 2,500 current roles that hold every word of a term (`"Head of Marketing"` reads roles with both `head` and `marketing`, then keeps the phrase); a title whose words more people hold than that (`"cto"`, `"director"`) is searched over part of them and the response says `sampled: true` — narrow it with more specific terms or `all`. Spellings with the same words (`"VP Marketing"`, `"VP of Marketing"`) share one read. More than three `any` terms with different words read fewer roles in total (ten: 1,000 between them), taken in table order whichever term matched, so a common term can fill most of the read and leave a rare one few rows: several searches of a few terms each cover every title better than one long list. `exclude`, `seniority`, `startedSince` and `destinationFilters` are applied after that read, so they narrow what survives it, not what is read: they do not make a sampled title complete. Sending `seniority: "head"` with a broad term does not keep the term safe — narrow the term itself. For Head of Growth, send `any: ["Head of Growth"]`, without the broad alternative `"Growth"`. A narrower search may still be sampled; never claim complete coverage.
  - `any` (string[], optional) — Title terms, ANY of which must appear in the person's current title. A multi-word term matches as a phrase, with at most one word between the words: `"vp finance"` matches "VP Finance" and "VP of Finance", not "VP of Engineering, Finance partner". Punctuation and case are ignored: `"founder"` matches "Co-Founder & CEO". A single common word matches across every function (`"automation"` finds manufacturing and procurement automation too) — prefer two-word terms and use `exclude`. An acronym is matched LITERALLY and expands to nothing: `"cto"` does not match "Chief Technology Officer", and the same role is written both ways inside one company, so send both forms — `["cto", "chief technology officer"]`. At named companies (`companyIds`, `companyWebsites`) every term is searched again at each company, so a long list there can run out of time: send the forms the user means, not every variant.
  - `all` (string[], optional) — Title terms that must ALL appear. Combine with `any` freely.
  - `exclude` (string[], optional) — Title terms that disqualify a person (`["assistant", "intern"]`).
- `startedSince` (string, optional) — Keeps moves whose new role STARTED in this month or later, read from `startedAt`. Works with `title`, `source` or `destination`, and with `detectedSince` and `seniority`: every filter sent applies. Month precision, because Trayo holds the start month, never the day: `2026-08` and `2026-08-25` both mean "started in August 2026 or later", so a "last 30 days" question gets the whole starting month back — check `startedAt` before calling a row inside a day-level window. Roles in the cutoff month cannot be confirmed inside a day-level last-30-days window. A role recorded with only a start year, or with no start date, does not match. Moves are still read from the 90-day detection window, so a month before that window finds only roles detected inside it. The start is checked after each move is read, so with a company a page reads a bounded number of recent moves and can come back short, even empty, with `hasMore: true`: follow `nextCursor`. A sampled source-only search cannot be paged, so `hasMore: true` there means narrow it.
- `startedBefore` (string, optional) — Exclusive upper bound on the start month, with source, destination or title. `2026-10` keeps roles started before October 2026. A full date uses its month. Combine with inclusive startedSince for adjacent, non-overlapping month slices. Year-only and missing start dates do not match; the 90-day detection window still applies.
- `destinationFilters` (object, optional) — With `title` only. Narrows the moves by the company they JOINED: `hq.countries` and `headcount`.
  - `hq` (object, optional)
    - `countries` (string[], required) — Countries of the headquarters of the company they JOINED: ISO-2 codes (`["US", "DE"]`) or country names (`["United States", "Germany"]`), any case. A name is read as its ISO-2 code. A company with no recorded country does not match.
  - `headcount` (object, optional) — Inclusive employee-count band of the company they JOINED, e.g. `{"min": 500}`. A company with no recorded size does not match.
    - `min` (integer, optional)
    - `max` (integer, optional)

**Responses**

- `200` → JobChangeSearchResponseDto: 
  - `source` (object | null, required) — The `source` company you sent, as resolved. Null when you sent none.
    - `companyId` (string, required) — Trayo's id for the company. Send it back as `companyId` to name this exact company again.
    - `name` (string, required) — The company as Trayo records it.
    - `website` (string | null, required) — The company's website domain as Trayo files it.
    - `linkedinUrl` (string | null, required) — The company's LinkedIn page, when Trayo has it.
    - `accountId` (uuid | null, required) — The workspace account for this company: the one you sent, or the account that already holds its website or LinkedIn page. Null when the company is not in your workspace.
    - `resolvedBy` (one of: "account_match" | "company_id" | "linkedin" | "website" | "redirect" | "name", required) — Which key identified the company — the same resolver the Trayo app uses, in its order. `account_match`: the account's existing match. `linkedin`: the LinkedIn page, as sent or as spelled by a website sent on its own. `website`: the website domain. `redirect`: the website, after following a cross-domain redirect to the company's current site. `name`: the name, the weakest key. A match is exact on the key you sent, which is only the company you meant if the key was. A slug or name can belong to a different company than the one you mean, and the answer will then describe that company. Read `name` and `website` back before acting, and prefer `website` as the key — a domain resolves to whoever owns it.
    - `nameMatch` (one of: "exact" | "approximate" | null, required) — Set only when `resolvedBy` is `name`, null otherwise. `exact`: the company's name equals the name looked up once legal suffixes (Inc, GmbH, Ltd) are dropped. `approximate`: the closest name Trayo holds, which can be a different company ("CR Management Consulting" answered with "m3 management consulting"). Check an `approximate` match against what you know of the company before using it, and leave it out when it does not fit.
  - `destination` (object | null, required) — The `destination` company you sent, as resolved. Null when you sent none.
    - `companyId` (string, required) — Trayo's id for the company. Send it back as `companyId` to name this exact company again.
    - `name` (string, required) — The company as Trayo records it.
    - `website` (string | null, required) — The company's website domain as Trayo files it.
    - `linkedinUrl` (string | null, required) — The company's LinkedIn page, when Trayo has it.
    - `accountId` (uuid | null, required) — The workspace account for this company: the one you sent, or the account that already holds its website or LinkedIn page. Null when the company is not in your workspace.
    - `resolvedBy` (one of: "account_match" | "company_id" | "linkedin" | "website" | "redirect" | "name", required) — Which key identified the company — the same resolver the Trayo app uses, in its order. `account_match`: the account's existing match. `linkedin`: the LinkedIn page, as sent or as spelled by a website sent on its own. `website`: the website domain. `redirect`: the website, after following a cross-domain redirect to the company's current site. `name`: the name, the weakest key. A match is exact on the key you sent, which is only the company you meant if the key was. A slug or name can belong to a different company than the one you mean, and the answer will then describe that company. Read `name` and `website` back before acting, and prefer `website` as the key — a domain resolves to whoever owns it.
    - `nameMatch` (one of: "exact" | "approximate" | null, required) — Set only when `resolvedBy` is `name`, null otherwise. `exact`: the company's name equals the name looked up once legal suffixes (Inc, GmbH, Ltd) are dropped. `approximate`: the closest name Trayo holds, which can be a different company ("CR Management Consulting" answered with "m3 management consulting"). Check an `approximate` match against what you know of the company before using it, and leave it out when it does not fit.
  - `jobChanges` (object[], required) — The moves that matched, most recently detected first, up to `limit` (25 by default, 100 at most). ONE ROW PER PERSON PER DESTINATION: a person is reported once per destination company however many times we heard about the move — so a person who changed jobs twice inside the window still comes back as two rows, one per destination. A `title` search returns one row per person: their newest move into a matching role. An empty array is a successful search that matched nothing in the bounded search; it does not prove nobody moved. Every row is a move BETWEEN two companies: a promotion or title change inside one company is never a row here. For promotions, watch the company with the workspace `job_change` signal instead (`create_signal`, then `run_discovery`).
    - `person` (object, required)
    - `source` (object, required) — The company they left, and their title there.
    - `destination` (object, required) — The company they joined, and their title there.
    - `detectedAt` (date-time, required) — When Trayo first observed the new role — not when the person started it. Rows come back most recently detected first.
    - `startedAt` (string | null, required) — When the person's role STARTED, as Trayo records it — `detectedAt` is when we saw it, and the two can be months apart. A PARTIAL date, not a timestamp: `2026-03` for a month we hold, or `2026` when we hold only a year. Null when we record no start date for the destination role, which is common. Both forms sort and compare as text (`startedAt >= '2026-08'`), and a year-only value never satisfies a month bound. Order the response by this field yourself if you want it — rows arrive ordered by `detectedAt`.
    - `startedAtPrecision` (one of: "month" | "year" | null, required) — How precise `startedAt` is: `month` for `2026-08`, `year` for `2026`, null when `startedAt` is null. Never `day`: Trayo does not hold the day a role started.
  - `hasMore` (boolean, required) — The page or its bounded scan filled, so more matches may remain. Send `nextCursor` back as `cursor`, with the same search, to get them. It reads the raw row count BEFORE duplicate records are collapsed, so a page can come back short — fewer rows than `limit` — and still be `true`. That is deliberate: it reports truncation rather than hiding it. Follow `hasMore` and `nextCursor`, never the row count. A sampled source-only search cannot be paged, so `true` there means narrow the search instead. A `title` search, and a company search with `startedSince` or `startedBefore`, and any indexed source-only search, reads a bounded number of moves per page, so a page can come back short, even empty, and still be `true`: follow the cursor. `hasMore: false` does not prove completeness, even when `sampled` is false: every search reads a bounded slice of recent moves before filtering, so matching moves can exist outside that slice.
  - `nextCursor` (string | null, required) — Send this `nextCursor` back as `cursor`, verbatim and with the SAME search, for the next page. Null when there is no next page. The walk resumes where this page stopped, so a later page neither repeats nor skips what an earlier one returned, even when new moves are detected while you page. Null on a sampled source-only search, which reads a fixed sample and cannot be paged. A short page can still carry one, so follow the cursor rather than counting rows.
  - `sampled` (boolean, required) — Whether the search used a sample. `true`: only `source` was sent and the search read a small fixed sample of recent moves and kept the ones that left that company. An empty answer does not mean nobody left; retrying gives the same answer while the underlying data is unchanged. `false` with only `source`: the search walked the newest moves out of that company instead of sampling them. `false`: `destination` was sent, with or without `source`; the search read a bounded slice of the newest moves into that company before filtering by source. `hasMore: false` does not prove completeness with either value of `sampled`; matching moves may be outside the slice. With `title`, `true` means more people hold the words of that title than the search reads, so it looked at part of them: narrow the title or add `all` terms rather than treating the answer as complete (`exclude`, `destinationFilters` and `startedSince` filter what was read and do not widen it). Promotions and same-company title changes never appear here. For promotions, and for checks you repeat on a company over time, use the workspace `job_change` signal (`create_signal`, then `run_discovery`). That signal is not a departure search and does not provide a complete list of who left.
- `400` → ErrorDto: None of source, destination or title was sent, `title` or `destinationFilters` was combined with a company, title had no any or all terms, `startedSince` or `startedBefore` was not a valid calendar month/date in YYYY-MM or YYYY-MM-DD form, either company is malformed, both resolve to the same company, `detectedSince` did not parse, `seniority` was not one of cxo, vp, director, head, `cursor` was sent to a sampled source-only backend, the start-month range was empty or reversed, or `cursor` did not decode.

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

`invalid_date` (`retry: fix_input`) — A date filter (`updatedSince`, `since`, `detectedSince`) did not parse. Send a plain date (`2026-06-07`, read as midnight UTC) or a full ISO-8601 timestamp (`2026-06-07T14:30:00Z`). Relative words such as `yesterday` or `last-quarter` are not accepted. `param` names the field.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: `account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.

`company_not_found` (`retry: do_not_retry`) — We could not identify this company — not from the account's own record, nor from a LinkedIn page, website (including its redirect), or name — so there are no people to search. Retrying will not help. Send the company's own website if you did not; for any company, `POST /v1/find` on the contacts lane searches the web instead. From `POST /v1/find` with `filters.companyWebsites`, this means NO website resolved and nothing was searched; the message names every website. For one that is ambiguous (several possible companies on the domain) it also names the candidates and their ids: send the right id as `filters.companyIds`. When only some websites fail, the search runs over the rest and lists the others in `unresolvedWebsites`.
- `429` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: Our people data is unavailable, the bounded query failed or ran past the request budget, the search read ran out of time (`data_search_timeout`: narrow what `message` names; a timeout is not an empty result), or the workspace already has as many job-change searches running as it may (`data_search_busy`; wait `details.retryAfterSeconds`).

`job_change_search_not_configured` (`retry: do_not_retry`) — Job-change search is unavailable. Retrying will not help. Contact support.

`job_change_search_failed` (`retry: retry_later`) — The bounded lookup failed or ran past its time budget. Nothing was written. Send the same job-change request again shortly.

`data_search_busy` (`retry: retry_later`) — Hosted search capacity (direct Find, post keyword search, job-change search, job posting search) is in use: your workspace already has as many of these searches running as it may, or the service is at capacity. Nothing ran. Wait for details.retryAfterSeconds, then retry, and send fewer at once.

`data_search_timeout` (`retry: fix_input`) — A hosted search (Find by filters, with or without `mode: direct`; post keyword search; job-change search; job posting search; post search over dates older than 90 days) ran out of time. Change what `message` names and retry; resending the same search runs out of time again, and a timeout is not an empty result.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/job-changes/search" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source":{"companyId":1}}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/job-changes/search", headers=HEADERS, json={"source":{"companyId":1}})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/posts/search

`searchPosts` — Find LinkedIn posts by topic or author

**Required permissions:** `people:read`.

Searches public LinkedIn posts from the last `windowDays` (default 30, maximum 30), or between inclusive `publishedFrom` and `publishedTo` dates within the last 365 days, for a `topic` by meaning and keyword, or by LinkedIn profile `author`. Omit topic to list an author's posts. Results are limited to posts Trayo holds, which go back 365 days; an earlier `publishedFrom` is refused with `validation_failed`. An empty result does not prove none were published. Posts arrive with a delay of about 10 days, so the most recent days may be incomplete. Returns up to `limit` posts per page (default 25, maximum 50), each with `postId`, `url`, the author, the author's company where known, and `commentCount`. Send a post's `url` or `postId` to POST /v1/posts/engagers to see who commented.

Follow `nextCursor` as `cursor` with identical search filters until `hasMore` is false. Dates stay fixed across pages. Author-only pages traverse current stored posts newest first; edits or inserts can change a traversal. Topic pages freeze a bounded ranked candidate pool in private temporary storage. Check `coverage`: exhausting that pool does not prove all stored matches were found. An expired or unavailable snapshot never silently restarts a search. Nothing is added to your workspace. Allow more than 120 seconds for the client timeout.

**Request body** (`application/json`)

- `topic` (string, optional) — What the posts should be about. Omit when searching all posts by an author.
- `author` (string, optional) — Exact LinkedIn profile URL or handle of the post author. Use alone for their latest posts, or with topic. A person's name as topic does not filter authors. A valid handle format does not verify the person's identity. Trayo holds person-authored posts only: a company page URL is refused.
- `windowDays` (integer, optional) — How many days back to search when publishedFrom and publishedTo are omitted, 1 to 30; default 30.
- `publishedFrom` (string, optional) — First publication date to include, YYYY-MM-DD. Send with publishedTo; overrides windowDays.
- `publishedTo` (string, optional) — Last publication date to include, YYYY-MM-DD. Send with publishedFrom; overrides windowDays.
- `limit` (integer, optional) — Maximum posts per page. Follow nextCursor with the same search filters until hasMore is false; page size may change.
- `cursor` (string, optional) — The previous nextCursor, verbatim with the same topic, author and date/window inputs. Effective dates stay fixed across pages. Author pages are a live traversal; topic pages use a bounded ranked snapshot.

**Responses**

- `200` → PostSearchResponseDto: 
  - `windowDays` (integer | null, required) — The relative window searched, or null for explicit publication dates.
  - `since` (string, required) — The earliest publication date searched, YYYY-MM-DD.
  - `until` (string, required) — The last publication date searched, YYYY-MM-DD.
  - `hasMore` (boolean, required) — More rows remain in this traversal. For topic searches, false means the bounded ranked candidate pool ended, not that all matching stored posts were found.
  - `nextCursor` (string | null, required) — Send back as cursor with the same search filters. Null at the end. Do not compute or modify it.
  - `coverage` (object, required) — Author-only pages traverse current stored matches and can change across edits. Topic pages freeze a bounded ranked pool until expiry; neither guarantees full LinkedIn coverage.
    - `mode` (one of: "author_live" | "ranked_snapshot", required)
    - `scope` (one of: "stored_matches" | "bounded_ranked_candidates", required)
    - `candidateLimit` (integer | null, required)
    - `snapshotExpiresAt` (string | null, required)
    - `droppedLowRelevance` (integer | null, required) — Topic searches only: ranked candidates left out for low relevance to the topic. A short page with a high count means the window held few posts on the topic, not that the search failed. Null for author-only searches.
    - `keywordOnly` (boolean, optional) — True when only keyword matches were ranked: matching posts by meaning ran out of time, which happens most on dates more than 90 days ago. The posts returned contain the topic's words; posts that discuss it in other words may be missing. Say so when you report the results. False on author-only searches.
    - `semanticPartial` (boolean, optional) — True when matching posts by meaning covered only part of the dates: of the posts collected most recently for them, only the newest were matched by meaning and the rest by their words only. Posts that discuss the topic in other words may be missing. Say so when you report the results. False on author-only searches.
    - `note` (string | null, optional) — What limited this search, in a sentence to pass on, or null when nothing did.
  - `posts` (object[], required) — Topic matches rank best first; author-only posts sort newest first. Empty means no stored matches for the requested identity, dates and topic; it does not verify the identity or prove that no posts were published. Nothing is saved to your workspace.
    - `postId` (string, required) — The LinkedIn post id. A string because it does not fit a JSON number. Send it, or `url`, as `post` to POST /v1/posts/engagers.
    - `url` (string | null, required) — The post on LinkedIn.
    - `publishedAt` (string, required) — Publication date, YYYY-MM-DD.
    - `text` (string, required) — The post text, cut at 600 characters.
    - `author` (object, required)
    - `company` (object | null, required) — The author's current company, or null when it is not known.
    - `commentCount` (integer, required) — Comments we hold for this post.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.

`post_author_not_covered` (`retry: fix_input`) — The post author is a LinkedIn company, school or showcase page. Trayo holds person-authored posts only, so no request reaches a page's posts. Send a person's profile URL or handle as author.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `post_search_not_configured` (`retry: do_not_retry`) — Post search is unavailable. Retrying will not help. Contact support.

`post_search_failed` (`retry: retry_later`) — The post search or engager read failed or exceeded its time budget. Nothing was saved; retry the same request shortly.

`data_search_timeout` (`retry: fix_input`) — A hosted search (Find by filters, with or without `mode: direct`; post keyword search; job-change search; job posting search; post search over dates older than 90 days) ran out of time. Change what `message` names and retry; resending the same search runs out of time again, and a timeout is not an empty result.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/posts/search" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"topic":"<topic>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/posts/search", headers=HEADERS, json={"topic":"<topic>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/posts/engagers

`getPostEngagers` — List the author and commenters of a LinkedIn post

**Required permissions:** `people:read`.

Send `post`, a LinkedIn post URL or the `postId` from POST /v1/posts/search. Returns the post author, then up to 500 commenters in comment order, each with their current title and company where we know them. A person who commented more than once appears once, with their first comment and `commentCount`. `indexed: false` means we did not find the post; it does not mean nobody commented. `reason` says why: `outside_coverage` when the post predates `coverageFrom`, the first day of the 365 days Trayo keeps, else `not_held`. `commentsChecked: false` means we hold the post but not its comments, so the author comes back without commenters. `commentsLoadedAt` is the latest Trayo load time among returned stored comments, or null if none are stored. Stored comments can be stale or incomplete, and `truncated: false` only means the stored rows fit within the read limit. LinkedIn share URLs are resolved to activity ids when the post is held.

Synchronous and read-only; nothing is added to your workspace. To keep people, send them to POST /v1/people/batch.

**Request body** (`application/json`)

- `post` (string, required) — A LinkedIn activity or share post URL, or the `postId` from a post search. Share URLs resolve only when Trayo holds the post. For a share or ugcPost URN, send the post's /posts/ URL instead; its number is not an activity ID.

**Responses**

- `200` → PostEngagersResponseDto: 
  - `postId` (string, required)
  - `url` (string | null, required)
  - `indexed` (boolean, required) — Whether Trayo found the post. False is not evidence that the post has no comments.
  - `reason` (one of: "outside_coverage" | "not_held" | null, required) — Why `indexed` is false, or null when it is true. `outside_coverage`: the post was published before `coverageFrom`, so Trayo cannot hold it. `not_held`: the post is inside coverage, but Trayo does not hold it. Trayo holds person-authored posts only, so a company page's post inside coverage is `not_held`.
  - `coverageFrom` (string, required) — The earliest publication date Trayo keeps posts for, YYYY-MM-DD. It moves forward daily.
  - `commentsChecked` (boolean, required) — True when the engagers come from Trayo's comment store for this post. False when Trayo holds the post but not its comment thread, or does not hold the post: empty commenters then say nothing about how many people commented.
  - `commentsLoadedAt` (string | null, required) — Latest Trayo load time among the returned stored comments, ISO 8601. Null means no stored comment rows. Source data may be older than this time; even with a date, other LinkedIn comments or replies may be missing.
  - `engagers` (object[], required) — The author first, when we hold the post, then one row per commenter in comment order: a person who commented more than once appears once, with their first comment and `commentCount`. Nothing is saved to your workspace.
    - `role` (one of: "author" | "commenter", required)
    - `name` (string | null, required)
    - `fullName` (string | null, required) — The person's name. The same value as `name`, under the name every person-returning route uses.
    - `linkedinUrl` (string | null, required) — The person's LinkedIn profile URL.
    - `linkedinUsername` (string | null, required) — The handle from that profile URL.
    - `photoUrl` (string | null, 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.
    - `headline` (string | null, required) — Their LinkedIn headline as shown on the post, often null for commenters.
    - `title` (string | null, required) — Their current job title, where we know it.
    - `company` (object | null, required) — Their current company, where we know it.
    - `comment` (string | null, required) — Their first comment on the post. Null for the author.
    - `commentedAt` (string | null, required) — When that comment was made, ISO 8601. Null for the author.
    - `commentCount` (integer, required) — How many comments this person left on the post. For the author, how many replies they left in the thread.
    - `reactionCount` (integer | null, required) — Reactions on their first comment. Null for the author.
  - `truncated` (boolean, required) — True when Trayo holds more than 500 comment rows and only the first were read. False does not mean every LinkedIn comment was collected.
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `post_search_not_configured` (`retry: do_not_retry`) — Post search is unavailable. Retrying will not help. Contact support.

`post_search_failed` (`retry: retry_later`) — The post search or engager read failed or exceeded its time budget. Nothing was saved; retry the same request shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/posts/engagers" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"post":"<post>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/posts/engagers", headers=HEADERS, json={"post":"<post>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/posts/search/keywords

`searchPostsByKeywords` — Search stored LinkedIn posts with explicit keyword groups

**Required permissions:** `people:read`.

Direct search with no generated query or semantic fallback. Split a request into concepts; put spelling variants or synonyms inside the same group. Groups are AND, alternatives are OR, and each alternative matches a normalized whole-token phrase. The response returns the executed `applied` groups. Optional authorIds constrain the authors.

Verifies at most 200 ranked candidates from the recent-post index and returns at most 50 matches, ranked by keyword relevance within that sample. This bounded direct search accepts at most seven days and omits bodies shorter than 120 characters. Coverage is always non-exhaustive; an empty sample cannot prove absence. Read coverage.candidateCapReached and coverage.resultsTruncated, including on empty responses. There is no global match count or continuation cursor. Narrow criteria for a more complete answer. The requested date window is not a guarantee that every recent post has arrived. Nothing is saved. The server has an eight-second request budget; busy and timeout responses are failures, not empty matches.

**Request body** (`application/json`)

- `concepts` (string[][], required) — Required concept groups. Alternatives inside each group are OR; groups are AND. Each alternative matches a whole-token phrase after punctuation normalization. For query expansion, put synonyms in the same group, e.g. [["CRM", "customer relationship management"], ["migration", "switching"]]. At most 12 alternatives total, with 12 ASCII letter/number tokens per phrase. The server executes these groups as sent; it does not invent synonyms.
- `excludedPhrases` (string[], optional) — Exclude a candidate containing any of these whole-token phrases. Applied after the bounded candidate search.
- `authorIds` (string[], optional) — Optional person dataset IDs. Keep IDs as strings.
- `windowDays` (integer, optional) — Publication lookback in UTC days. This is a requested window, not a guarantee of source freshness.
- `limit` (integer, optional)

**Responses**

- `200` → PostKeywordSearchResponseDto: 
  - `applied` (object, required)
    - `concepts` (string[][], required)
    - `excludedPhrases` (string[], required)
    - `authorIds` (string[], required)
    - `windowDays` (integer, required)
    - `limit` (integer, required)
  - `since` (string, required) — Inclusive earliest publication date searched, YYYY-MM-DD.
  - `through` (string, required) — Latest publication date searched, YYYY-MM-DD.
  - `posts` (object[], required)
    - `postId` (string, required)
    - `employeeId` (string | null, required)
    - `url` (string | null, required)
    - `publishedAt` (string, required)
    - `text` (string, required)
    - `textTruncated` (boolean, required)
    - `author` (object, required)
  - `coverage` (object, required)
    - `mode` (string, required)
    - `exhaustive` (boolean, required) — A ranked sample of the recent-post index. Never a complete scan of all stored or published posts, even if the candidate cap was not reached.
    - `maxWindowDays` (number, required)
    - `minimumIndexedBodyCharacters` (number, required) — The recent-post index omits shorter bodies. Older posts and sources not yet indexed are also outside coverage.
    - `candidateLimit` (number, required)
    - `candidatesExamined` (integer, required)
    - `candidateCapReached` (boolean, required) — The ranked candidate limit was reached, or the search stopped reading candidates before filling it. Even when false, this is not an exhaustive search.
    - `matchingPostsInSample` (integer, required) — Matches among examined candidates, not a global total.
    - `resultsTruncated` (boolean, required) — The candidate cap or result limit trimmed this sample. A false value does not make retrieval exhaustive.
    - `order` (string, required) — Keyword relevance within the examined sample; not a global ranking of all stored posts.
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `post_search_not_configured` (`retry: do_not_retry`) — Post search is unavailable. Retrying will not help. Contact support.

`post_search_failed` (`retry: retry_later`) — The post search or engager read failed or exceeded its time budget. Nothing was saved; retry the same request shortly.

`data_search_busy` (`retry: retry_later`) — Hosted search capacity (direct Find, post keyword search, job-change search, job posting search) is in use: your workspace already has as many of these searches running as it may, or the service is at capacity. Nothing ran. Wait for details.retryAfterSeconds, then retry, and send fewer at once.

`data_search_timeout` (`retry: fix_input`) — A hosted search (Find by filters, with or without `mode: direct`; post keyword search; job-change search; job posting search; post search over dates older than 90 days) ran out of time. Change what `message` names and retry; resending the same search runs out of time again, and a timeout is not an empty result.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/posts/search/keywords" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"concepts":[["<concepts>"]]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/posts/search/keywords", headers=HEADERS, json={"concepts":[["<concepts>"]]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/stakeholders/search

`searchStakeholders` — Find the stakeholders at a company that fit a definition

**Required permissions:** `research:trigger`.

Given a company — any company, by its website (or LinkedIn page or name), or an account in your workspace — and a stakeholder definition, return the people at it who fit, best first, each with a reason. **Synchronous and read-only**: the answer comes back in one response, typically in 5–20 seconds (the server gives up at 25 with `503 stakeholder_search_failed`, so set your client timeout above that and retry once on `retry_later`), and nothing is written to your workspace. Import the ones you want with `POST /v1/people/batch`.

**Company identity.** Trayo identifies the company from its LinkedIn page first, then its website, then its name. The response includes people who fit your definition and explains each result in `whyThisPerson`. `company.resolvedBy` says which identity was used. Before importing anyone, make sure that `company.name` and `company.website` identify the intended company. A guessed handle or a common name can identify a different company.

With usage enforcement on: **What it costs.** Each search that returns draws once on the workspace's usage quota; one refused before it runs (`404`, `400`) or one that fails (`503`) does not. Over the quota is `402 quota_exceeded`; over the workspace's rolling 24-hour usage limit is `429 quota_daily_limit`.

 With it off, usage has no quotas. **The definition.** Send one per call, in plain prose — the roles that own or buy what you sell, the roles that influence, the roles to skip. Omit it to use the workspace's own definition; if that is unset too, a search for senior roles applies and `definition.source` says `default` so you know the result is senior people of any function rather than your buying committee. A definition sent here changes nothing on the workspace.

**Read `candidates` with `people`.** `candidates: 0` means Trayo found no people with the requested keywords at this company. `candidates > 0` with `people: []` means the selection judged none of them to fit — a real answer, not an error. Fewer than `limit` come back when fewer fit.

**Near-misses.** When nobody fits, `nearMisses` holds up to 3 of the closest candidates, each with `belowThreshold: true` and a reason saying what their role lacks, and `next` names their titles. They did not fit: offer them as alternatives rather than importing them as the buying committee.

**Personas.** Each person carries `matchedPersona`, and `coverage` lists every persona with whether anyone in `people` matched it, with `coveredBy` giving the zero-based row indexes in `people` of those who fit it (`next` names them when none carries the persona label, a partial fit only) — so a persona with no match (no champion, say) is stated, not guessed from titles, and `next` says what to do about it. The names come from `personas` in the request, or from the workspace's saved personas when its definition applies. Each label is checked after selection against the job's function and seniority (a saved persona's seniority and rationale are read too) and replaced or cleared when it does not hold; the person stays. One person can cover several personas: a CTO also covers "VP Eng".

**Repeatable.** Repeating a search within a week normally returns the same people.

**Many companies.** For up to 50 companies at once, use `POST /v1/stakeholders/search/batch` rather than one call per company.

**Compared with `POST /v1/find`.** Find's contacts lane searches the web and identifies people from any source; this searches Trayo's data for people who fit your definition. Use find when the company is not in our data (`company_not_found`) or when you want breadth over fit.

**Request body** (`application/json`)

- `company` (object, required)
  - `companyId` (integer | string, optional) — Trayo's own id for the company, as a number or numeric string: the `companyId` a post, post-engager or person-research result carries, or the `id` of a company find result. Not a workspace account UUID. When sent, it is the key: the company is read by this id and nothing else is tried, so an id Trayo does not hold is `company_not_found`. A `name`, `website` or `linkedinUrl` sent beside it is not used.
  - `accountId` (uuid, optional) — An account in your workspace, on its own. Its existing match is used when it has one; otherwise the account is resolved from its website, LinkedIn handle and name exactly as the app would, so an account we have not yet matched still works when the company is in our data.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.
- `definition` (string, optional) — The stakeholder definition to search with, in plain prose: the roles that own or buy what you sell, the roles that influence the decision, and the roles to skip. Optional: when omitted, the workspace's own definition is used, and when that is unset too, a seniority-only seed (chiefs, VPs, directors of any function). `definition.source` in the response says which applied. Passing one here does not change the workspace setting.
- `limit` (integer, optional) — How many people to return, 5 by default, 10 at most. Fewer come back when fewer fit.
- `personas` (string[], optional) — Persona names to label each person with, e.g. `["CISO", "IT leader", "Champion"]`. Optional: when omitted and the workspace's own definition applies, the workspace's saved personas are used. A definition sent with the request has no persona names of its own, so without this field its people come back with `matchedPersona: null` and `coverage: []`.

**Responses**

- `200` → StakeholderSearchResponseDto: The selected people, with the company as resolved and the definition as applied. `people` may be empty — read `candidates`.
  - `company` (object, required)
    - `companyId` (string, required) — Trayo's id for the company. Send it back as `companyId` to name this exact company again.
    - `name` (string, required) — The company as Trayo records it.
    - `website` (string | null, required) — The company's website domain as Trayo files it.
    - `linkedinUrl` (string | null, required) — The company's LinkedIn page, when Trayo has it.
    - `accountId` (uuid | null, required) — The workspace account for this company: the one you sent, or the account that already holds its website or LinkedIn page. Null when the company is not in your workspace.
    - `resolvedBy` (one of: "account_match" | "company_id" | "linkedin" | "website" | "redirect" | "name", required) — Which key identified the company — the same resolver the Trayo app uses, in its order. `account_match`: the account's existing match. `linkedin`: the LinkedIn page, as sent or as spelled by a website sent on its own. `website`: the website domain. `redirect`: the website, after following a cross-domain redirect to the company's current site. `name`: the name, the weakest key. A match is exact on the key you sent, which is only the company you meant if the key was. A slug or name can belong to a different company than the one you mean, and the answer will then describe that company. Read `name` and `website` back before acting, and prefer `website` as the key — a domain resolves to whoever owns it.
    - `nameMatch` (one of: "exact" | "approximate" | null, required) — Set only when `resolvedBy` is `name`, null otherwise. `exact`: the company's name equals the name looked up once legal suffixes (Inc, GmbH, Ltd) are dropped. `approximate`: the closest name Trayo holds, which can be a different company ("CR Management Consulting" answered with "m3 management consulting"). Check an `approximate` match against what you know of the company before using it, and leave it out when it does not fit.
  - `definition` (object, required)
    - `text` (string, required) — The definition the search actually ran with.
    - `source` (one of: "request" | "workspace" | "default", required) — `request`: you sent one. `workspace`: the workspace's own definition. `default`: neither existed, so a seniority-only seed was used — the results are senior people of any function, not your buying committee.
  - `candidates` (integer, required) — How many people at the company the keyword search found before selection. Zero with `people: []` means Trayo has nobody matching the keywords; a positive number with `people: []` means the selection judged none of them to fit the definition.
  - `people` (object[], required) — The selected people, at most `limit`. With several personas, each persona that has someone who fits gets a share: their people take turns, so one persona cannot fill `limit` alone. Repeating a search within a week normally returns the same people. NOT written to your workspace: import the ones you want with `POST /v1/people/batch` (`fullName`, `title`, `linkedinUrl`, `photoUrl`, plus `accountId` or `company`).
    - `fullName` (string, required)
    - `title` (string | null, required) — Their current title at the searched company; null when Trayo has none, and then `headline` says what they do. Wherever you name the person, give this title and `linkedinUrl` with `fullName`.
    - `headline` (string | null, required)
    - `location` (object | null, 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. Breaking type change: this field used to be a string, usually null; it is now this object, the person's stated LinkedIn location.
    - `linkedinUrl` (string | null, 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`.
    - `photoUrl` (string | null, 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.
    - `otherCurrentEmployers` (string[], required) — Up to three other companies with current roles, including board or advisory affiliations. These names do not establish employment, salary or buying authority. An empty list is not proof of employment at the searched company.
    - `otherCurrentRoles` (object[], required) — Up to six current roles at the other companies, with titles for distinguishing employment from affiliations. Outside board or advisory seats do not disqualify a buyer employed at the searched company. Missing titles or an empty list mean there may be insufficient evidence; do not infer who pays the person.
    - `whyThisPerson` (string, required) — Why this person fits the definition, in the selection's own words. When the persona check replaced or cleared `matchedPersona`, what the profile's own title and headline say instead. On a near-miss: what their role covers and what it lacks.
    - `matchedPersona` (string | null, required) — The persona this person matched, one of the names in `coverage`. Checked after selection against the job's function and seniority, and replaced or cleared when it does not hold. Null when no persona holds, or when the search had no persona names (see `personas`).
    - `belowThreshold` (boolean, required) — `false` on every row of `people`. `true` on every row of `nearMisses`: the selection judged this person NOT to fit the definition.
  - `nearMisses` (object[], required) — Only when `people` is empty and `candidates` is above zero: up to 3 of the closest candidates, best first, each with `belowThreshold: true`. They did NOT fit the definition; show them as alternatives, and import one only when the user picks it. Empty otherwise.
    - `fullName` (string, required)
    - `title` (string | null, required) — Their current title at the searched company; null when Trayo has none, and then `headline` says what they do. Wherever you name the person, give this title and `linkedinUrl` with `fullName`.
    - `headline` (string | null, required)
    - `location` (object | null, 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. Breaking type change: this field used to be a string, usually null; it is now this object, the person's stated LinkedIn location.
    - `linkedinUrl` (string | null, 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`.
    - `photoUrl` (string | null, 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.
    - `otherCurrentEmployers` (string[], required) — Up to three other companies with current roles, including board or advisory affiliations. These names do not establish employment, salary or buying authority. An empty list is not proof of employment at the searched company.
    - `otherCurrentRoles` (object[], required) — Up to six current roles at the other companies, with titles for distinguishing employment from affiliations. Outside board or advisory seats do not disqualify a buyer employed at the searched company. Missing titles or an empty list mean there may be insufficient evidence; do not infer who pays the person.
    - `whyThisPerson` (string, required) — Why this person fits the definition, in the selection's own words. When the persona check replaced or cleared `matchedPersona`, what the profile's own title and headline say instead. On a near-miss: what their role covers and what it lacks.
    - `matchedPersona` (string | null, required) — The persona this person matched, one of the names in `coverage`. Checked after selection against the job's function and seniority, and replaced or cleared when it does not hold. Null when no persona holds, or when the search had no persona names (see `personas`).
    - `belowThreshold` (boolean, required) — `false` on every row of `people`. `true` on every row of `nearMisses`: the selection judged this person NOT to fit the definition.
  - `coverage` (object[], required) — One entry per persona the search labelled against, in the order they were given: `matched` is true when someone in `people` fits it, by their `matchedPersona` or by the same check, so one person can cover several personas (a CTO also covers "VP Eng"). `coveredBy` names those rows; `matched` is true exactly when it is non-empty. Near-misses do not count. `candidates` is how many people that persona's own title search found at the company: zero means nobody there holds such a role in Trayo's data; above zero with `matched: false` means candidates existed and none fit. One person found by two personas counts for both, so these can add up to more than the top-level `candidates`. Empty when the search had no persona names (see `personas`).
    - `persona` (string, required)
    - `matched` (boolean, required)
    - `coveredBy` (integer[], required) — Zero-based row indexes into this company's `people`, ascending. A listed row whose `matchedPersona` is a different persona or null fits this persona only partially; when no listed row carries it, `next` names them.
    - `candidates` (integer, required)
  - `next` (string | null, required) — What to do about an empty `people` or an unmatched persona, and which people fit a persona only partially. Null when people were found and every persona has a labelled match.
- `400` → ErrorDto: `company` with neither an `accountId` nor any of `website` / `linkedinUrl` / `name`, or with `accountId` alongside them; a `linkedinUrl` that is a LinkedIn URL but not a company page; a blank definition; a `limit` outside 1–10; or an unknown field. `param` names it.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: `account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.

`company_not_found` (`retry: do_not_retry`) — We could not identify this company — not from the account's own record, nor from a LinkedIn page, website (including its redirect), or name — so there are no people to search. Retrying will not help. Send the company's own website if you did not; for any company, `POST /v1/find` on the contacts lane searches the web instead. From `POST /v1/find` with `filters.companyWebsites`, this means NO website resolved and nothing was searched; the message names every website. For one that is ambiguous (several possible companies on the domain) it also names the candidates and their ids: send the right id as `filters.companyIds`. When only some websites fail, the search runs over the rest and lists the others in `unresolvedWebsites`.
- `429` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: 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.
- `503` → ErrorDto: `stakeholder_search_not_configured` (`retry: do_not_retry`) — Stakeholder search is unavailable. Retrying will not help. Contact support. For another way to find people, use `POST /v1/find` on the contacts lane.

`stakeholder_search_failed` (`retry: retry_later`) — Stakeholder search failed or timed out. Nothing was saved. Send the same request again shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/stakeholders/search" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"company":{"companyId":1}}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/stakeholders/search", headers=HEADERS, json={"company":{"companyId":1}})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/stakeholders/search/batch

`searchStakeholdersBatch` — Find the stakeholders at up to 50 companies in one call

**Required permissions:** `research:trigger`.

The same search as `POST /v1/stakeholders/search`, for every company in `companies` (1 to 50), under one `definition`, `personas` and `limit`. The companies are searched up to ten at a time (fewer with more than five personas): 20 companies with five personas take about 45 seconds, and 50 can take close to two minutes, against 8–14 seconds each one after another. The batch has a 120-second timeout by default. Set your client timeout above 120 seconds. **Synchronous and read-only**: nothing is written to your workspace.

**One outcome per company.** Every company ends in exactly one of `results` or `errors`, matched by `index`. A company that cannot be found (`company_not_found`) or whose search fails (`stakeholder_search_failed`, `retry_later`) is an `errors` entry and never fails the rest; send just those companies again. Each `results` entry has the single route's shape without `definition`, which is stated once at the top.

With usage enforcement on: **What it costs.** One draw on the workspace's usage quota per company in `results` that a single search would have charged; a company in `errors` costs nothing. The whole call is refused with `402 quota_exceeded` when the quota cannot cover every company that was found, and with `429 quota_daily_limit` over the rolling 24-hour usage limit. This route draws on the smaller `expensive` rate-limit budget. With it off, usage has no quotas. 

**Request body** (`application/json`)

- `companies` (object[], required) — The companies to search, 1 to 50, each named the same way as `company` on `POST /v1/stakeholders/search`. Results come back by `index` into this array. Include every account you will report on, side tables and lists included: a reported account with no search behind it has no one to talk to.
  - `companyId` (integer | string, optional) — Trayo's own id for the company, as a number or numeric string: the `companyId` a post, post-engager or person-research result carries, or the `id` of a company find result. Not a workspace account UUID. When sent, it is the key: the company is read by this id and nothing else is tried, so an id Trayo does not hold is `company_not_found`. A `name`, `website` or `linkedinUrl` sent beside it is not used.
  - `accountId` (uuid, optional) — An account in your workspace, on its own. Its existing match is used when it has one; otherwise the account is resolved from its website, LinkedIn handle and name exactly as the app would, so an account we have not yet matched still works when the company is in our data.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.
- `definition` (string, optional) — The stakeholder definition to search with, in plain prose: the roles that own or buy what you sell, the roles that influence the decision, and the roles to skip. Optional: when omitted, the workspace's own definition is used, and when that is unset too, a seniority-only seed (chiefs, VPs, directors of any function). `definition.source` in the response says which applied. Passing one here does not change the workspace setting.
- `limit` (integer, optional) — How many people to return, 5 by default, 10 at most. Fewer come back when fewer fit.
- `personas` (string[], optional) — Persona names to label each person with, e.g. `["CISO", "IT leader", "Champion"]`. Optional: when omitted and the workspace's own definition applies, the workspace's saved personas are used. A definition sent with the request has no persona names of its own, so without this field its people come back with `matchedPersona: null` and `coverage: []`.

**Responses**

- `200` → StakeholderBatchSearchResponseDto: One outcome per submitted company, in `results` or `errors`.
  - `definition` (object, required)
    - `text` (string, required) — The definition the search actually ran with.
    - `source` (one of: "request" | "workspace" | "default", required) — `request`: you sent one. `workspace`: the workspace's own definition. `default`: neither existed, so a seniority-only seed was used — the results are senior people of any function, not your buying committee.
  - `results` (object[], required) — One entry per company that was searched, in `index` order. A mixed batch never fails as a whole.
    - `company` (object, required)
    - `candidates` (integer, required) — How many people at the company the keyword search found before selection. Zero with `people: []` means Trayo has nobody matching the keywords; a positive number with `people: []` means the selection judged none of them to fit the definition.
    - `people` (object[], required) — The selected people, at most `limit`. With several personas, each persona that has someone who fits gets a share: their people take turns, so one persona cannot fill `limit` alone. Repeating a search within a week normally returns the same people. NOT written to your workspace: import the ones you want with `POST /v1/people/batch` (`fullName`, `title`, `linkedinUrl`, `photoUrl`, plus `accountId` or `company`).
    - `nearMisses` (object[], required) — Only when `people` is empty and `candidates` is above zero: up to 3 of the closest candidates, best first, each with `belowThreshold: true`. They did NOT fit the definition; show them as alternatives, and import one only when the user picks it. Empty otherwise.
    - `coverage` (object[], required) — One entry per persona the search labelled against, in the order they were given: `matched` is true when someone in `people` fits it, by their `matchedPersona` or by the same check, so one person can cover several personas (a CTO also covers "VP Eng"). `coveredBy` names those rows; `matched` is true exactly when it is non-empty. Near-misses do not count. `candidates` is how many people that persona's own title search found at the company: zero means nobody there holds such a role in Trayo's data; above zero with `matched: false` means candidates existed and none fit. One person found by two personas counts for both, so these can add up to more than the top-level `candidates`. Empty when the search had no persona names (see `personas`).
    - `next` (string | null, required) — What to do about an empty `people` or an unmatched persona, and which people fit a persona only partially. Null when people were found and every persona has a labelled match.
    - `index` (integer, required) — Which entry of the submitted `companies` this result is for, 0-based.
  - `errors` (object[], required) — One entry per company that could not be searched. It costs nothing; send it again on `retry_later`.
    - `index` (integer, required) — Which entry of the submitted `companies` this failure is for, 0-based.
    - `code` (one of: "data_search_busy" | "data_search_timeout" | "validation_failed" | "malformed_cursor" | "invalid_date" | "unknown_account" | "unknown_signal" | "unauthorized" | "insufficient_scope" | "forbidden" | "not_found" | "account_not_found" | "signal_not_found" | "event_not_found" | "discovery_run_not_found" | "person_not_found" | "list_not_found" | "discovery_not_found" | "list_member_not_found" | "method_not_allowed" | "idempotency_in_progress" | "conflict" | "signal_key_conflict" | "idempotency_key_reused" | "list_in_use" | "quota_daily_limit" | "quota_exceeded" | "lookup_limit_reached" | "lookup_start_failed" | "contact_lookup_not_found" | "phone_lookup_failed" | "phone_lookup_expired" | "contact_lookup_failed" | "company_not_found" | "company_resolution_timed_out" | "too_many_names" | "job_change_search_not_configured" | "job_change_search_failed" | "lookalike_search_not_configured" | "lookalike_search_failed" | "competitor_roster_not_configured" | "competitor_roster_failed" | "post_search_not_configured" | "post_search_failed" | "job_search_not_configured" | "job_search_failed" | "post_author_not_covered" | "stakeholder_search_not_configured" | "stakeholder_search_failed" | "stakeholder_search_not_found" | "person_ask_not_configured" | "person_ask_failed" | "person_profile_not_found" | "person_profile_too_thin" | "research_not_configured" | "research_failed" | "find_needs_indexed_filter" | "rate_limited" | "internal_error" | "find_not_configured" | "find_response_failed" | "discovery_run_too_large" | "mcp_result_delivery_failed" | "mcp_result_not_found" | "mcp_result_expired" | "mcp_tool_removed" | "model_unavailable" | "find_unavailable" | "find_timeout" | "app_not_found" | "deployment_not_found" | "app_slug_unavailable" | "app_not_ready" | "app_state_conflict" | "archive_upload_missing" | "archive_too_large" | "archive_rejected" | "public_mode_not_allowed" | "hosted_apps_unavailable" | "public_records_not_configured" | "public_records_unavailable" | "public_records_throttled", required) — Why this company failed, in the error envelope's `code` vocabulary: the code a single search would return, e.g. `company_not_found` or `stakeholder_search_failed`.
    - `retry` (one of: "fix_input" | "retry_later" | "do_not_retry", required) — What to do about the failure, and the only field you need in order to decide whether to send the request again. `fix_input` — the request is wrong; change it and retry. `retry_later` — the request is right, the system is not ready; retry it unchanged, with backoff and a FINITE number of attempts. On a 500 (`internal_error`) this especially does not mean retry indefinitely: the failure is not known to be transient, only that nothing about your request needs changing, so give up after a few attempts rather than looping. `do_not_retry` — retrying will never help. Fixed per `code`; see the table above. Always present, on every error, including any code added after this document — so a client that does not recognise a `code` can still act correctly by reading this field alone.
    - `message` (string, required)
    - `param` (string, optional) — Names the offending field, e.g. `companies[2]`.
- `400` → ErrorDto: `companies` empty or over 50, a company named neither by `accountId` nor by any of `website` / `linkedinUrl` / `name`, a blank definition, a `limit` outside 1–10, or an unknown field. `param` names it.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: 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.
- `503` → ErrorDto: `stakeholder_search_not_configured` (`retry: do_not_retry`) — Stakeholder search is unavailable. Retrying will not help. Contact support. For another way to find people, use `POST /v1/find` on the contacts lane.

`stakeholder_search_failed` (`retry: retry_later`) — Stakeholder search failed or timed out. Nothing was saved. Send the same request again shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/stakeholders/search/batch" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"companies":[{"companyId":1}]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/stakeholders/search/batch", headers=HEADERS, json={"companies":[{"companyId":1}]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/contacts/lookup

`lookupContact` — Find an email or phone for a person you describe

**Required permissions:** `body.fields` includes `email`: `people:enrich_email`; `body.fields` includes `phone`: `people:enrich_phone`. All scopes for the selected fields are required. Omitting `body.fields` uses `["email"]` and requires `people:enrich_email`. Malformed values: no additional scopes; unknown fields add no scopes. Request validation still applies.

Give a person — their name, and their LinkedIn profile or where they work — and get their email back in the same response, and their phone as a lookup to poll. The person does not have to exist in your workspace, and this call does not create them: it is `POST /v1/people/{id}/enrich` with the person row taken out of the middle, for the case where you are still deciding whether someone is worth keeping.

**Email is synchronous.** Typically a few seconds, and the server gives up at 120 seconds by default with `503 contact_lookup_failed`, so set your client timeout above that. Import the people you want afterwards with `POST /v1/people` (send the `email` you got back as the person's email).

**Phone is asynchronous.** Ask for it with `fields: ["phone"]` and the response is `202` with `phone.status: "pending"` and a `phone.lookupId`; collect it from `GET /v1/contacts/lookups/{lookupId}`. Phone lookups typically take one to two minutes, but can take up to four minutes. Email asked for in the same call is still answered inline, in the same response.

**What it costs.** One lookup per field when a search starts (a phone counts when it starts, so polling is free), counted against your plan's allowance for that field — email and phone have separate limits. A lookup here and a lookup on a person draw on the same allowance for the same field. Over the limit is `402 lookup_limit_reached` before any search starts. The response carries `quota`: the allowance left per field after this call, the same numbers `GET /v1/whoami` reports.

**Asking twice is free.** The workspace's answer for one identity is remembered — a month for a found address, a week for `not_found` — and a repeat request reads it: `source: "saved"`, no new lookup, and nothing counted against your allowance. So a retry after `not_found` costs nothing.

**A found address is not a verified owner.** `status: "found"` means a search returned the address and nothing suggests it belongs to someone else. `status: "unconfirmed"` means a search returned it but `email.identityConfidence` is `conflict`: the address is still in `value`, and a human should look before anyone sends to it. `email.identityConfidence` (`match`, `conflict` or `unknown`) says why. It compares the address against the `fullName` you sent, always, and against the `company.website` you sent, when you sent one. Include the known employer website even with a LinkedIn profile; without it, a matching name can produce `match` at the wrong employer. `match` does not prove mailbox ownership or current employment, and this field does not block sending.

**`not_found` is an answer.** The lookup finished without a value and returns `200`. A `503` means the question was never answered — nothing charged, nothing stored.

**Partial success is the contract.** When you ask for both fields and one fails because of a plan limit or a lookup error, the call still answers. That field reads `status: "failed"` with `error.code`, `error.retry` and `error.message`, exactly what an error response would have carried. The other field keeps its answer and its `lookupId`. Nothing is charged for a field that failed. The call itself fails only when every field you asked for did.

**What to send.** Include the LinkedIn profile when known to improve the chance of finding contact details. Otherwise, send the company website. A name alone returns `400`.

**Request body** (`application/json`)

- `fullName` (string, required) — The person's full name, as it reads on their profile.
- `linkedinUrl` (string, optional) — The person's LinkedIn profile, as its URL — the `linkedinUrl` a `POST /v1/find` contacts result carries, copied through — or as the bare username. Include it when known to improve the chance of finding contact details.
- `company` (object, optional) — Where the person works. Include it when known to help identify the person.
  - `name` (string, optional) — The employer's name. Used alongside the website, never instead of it.
  - `website` (string, optional) — The employer's website, as a URL or a domain (`https://zapier.com`, `zapier.com`). Required when you do not send a LinkedIn profile. Include the known employer website even with a profile so `email.identityConfidence` can check the employer too.
- `fields` (one of: "email" | "phone"[], optional) — Which values to look up. Defaults to `["email"]`, the synchronous one, so ask for `phone` explicitly. A request that includes `phone` comes back `202` with that field `pending` and a `lookupId` to poll. Phone lookups can take several minutes. Email in the same request is still answered inline.

**Responses**

- `200` → ContactLookupResultDto: The lookup ran, a stored answer was returned, or nothing could run (`not_attempted`). Read `email.status` for the outcome (`unconfirmed` for a found address that may be someone else's), `email.identityConfidence` for whether the address looks like the person you described, and `email.source` for whether this call paid for it.
  - `person` (object, required) — The identity as the lookup understood it. Check it when the answer surprises you.
    - `fullName` (string, required)
    - `linkedinUrl` (string | null, required) — The profile the lookup used, canonicalized from whatever form you sent.
    - `company` (object, required)
  - `email` (object, required)
    - `value` (string | null, required) — What was found, or null.
    - `status` (one of: "found" | "not_found" | "pending" | "not_attempted" | "not_requested" | "failed" | "unconfirmed", required) — `found`: a value was found. `not_found`: the lookup finished without a value. A repeat request returns the saved result without another charge. `pending`: the lookup is running. Poll `lookupId`. `not_attempted`: the lookup is unavailable or the supplied details are insufficient. Nothing was searched or charged. For phone lookups, include `linkedinUrl`. `not_requested`: absent from `fields`. `failed`: this field failed. Read `error` for the reason and retry instructions. Nothing was charged for this field. If every requested field fails, the whole call returns an error. `unconfirmed` (email only): an address was found, but `identityConfidence` is `conflict`, so it may belong to someone else. `value` still holds it; have a human check it before sending. Branch on this field: `found` is never given for a `conflict` address.
    - `foundAt` (date-time | null, required) — When the lookup ran. For a saved result, this is the time of the original lookup.
    - `source` (one of: "lookup" | "saved" | null, required) — `lookup`: this call started a lookup or returned one that is still running. `saved`: this call returned a saved result without another charge. Null when `not_requested`.
    - `lookupId` (uuid | null, required) — Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`.
    - `error` (object | null, required) — Set only when `status` is `failed`: the `code`, `retry` and `message` the call would have answered with had this been the only field requested. Branch on it exactly as you would on an error response.
    - `identityConfidence` (one of: "match" | "conflict" | "unknown" | null, required) — Whether a FOUND address looks like THIS person's. Two comparisons: the address's local-part against the `fullName` you sent, and the address's domain against the `company.website` you sent. The name comparison always runs, because `fullName` is required. The domain comparison runs only when you send a website — `person.company.website` echoes back the domain supplied to that comparison, or null when there was none. Either comparison can have no usable evidence. Apply these rules in order: `conflict` when either comparison disagrees, even if the other agrees; otherwise `unknown` for a similar-looking company domain or a sibling government domain, even when the name matches; otherwise `match` when at least one comparison agrees; otherwise `unknown`. A free-mail address or an absent or unusable employer website provides no employer evidence, so the name alone can still produce `match` or `conflict`. Include the known employer website even when you send a profile. `match` does not prove mailbox ownership or current employment. A `conflict` turns `status` into `unconfirmed`: have a human look before sending; this field does not block sending. Null when no address was found.
  - `phone` (object, required) — Phone lookups can take several minutes. For `pending`, poll `lookupId`. `not_attempted` means the lookup is unavailable or the supplied details are insufficient. Include `linkedinUrl` when known. No lookup starts and nothing is charged for `not_attempted`.
    - `value` (string | null, required) — What was found, or null.
    - `status` (one of: "found" | "not_found" | "pending" | "not_attempted" | "not_requested" | "failed", required) — `found`: a value was found. `not_found`: the lookup finished without a value. A repeat request returns the saved result without another charge. `pending`: the lookup is running. Poll `lookupId`. `not_attempted`: the lookup is unavailable or the supplied details are insufficient. Nothing was searched or charged. For phone lookups, include `linkedinUrl`. `not_requested`: absent from `fields`. `failed`: this field failed. Read `error` for the reason and retry instructions. Nothing was charged for this field. If every requested field fails, the whole call returns an error.
    - `foundAt` (date-time | null, required) — When the lookup ran. For a saved result, this is the time of the original lookup.
    - `source` (one of: "lookup" | "saved" | null, required) — `lookup`: this call started a lookup or returned one that is still running. `saved`: this call returned a saved result without another charge. Null when `not_requested`.
    - `lookupId` (uuid | null, required) — Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`.
    - `error` (object | null, required) — Set only when `status` is `failed`: the `code`, `retry` and `message` the call would have answered with had this been the only field requested. Branch on it exactly as you would on an error response.
  - `quota` (object, optional) — Email and phone lookup usage and any active allowances AFTER this call, counted separately. `limit` and `remaining` are `null` when no lookup cap applies. If `remaining` is a number, size your next request by it: people past that allowance are skipped. Omitted only when the allowance could not be read; `GET /v1/whoami` (MCP: trayo_whoami) reports the same numbers.
    - `email` (object, required)
    - `phone` (object, required)
- `202` → ContactLookupResultDto: A phone lookup was started. Same body as `200`; `phone.status` is `pending` and `phone.lookupId` is the id to poll. An email asked for in the same call is answered in this response.
  - `person` (object, required) — The identity as the lookup understood it. Check it when the answer surprises you.
    - `fullName` (string, required)
    - `linkedinUrl` (string | null, required) — The profile the lookup used, canonicalized from whatever form you sent.
    - `company` (object, required)
  - `email` (object, required)
    - `value` (string | null, required) — What was found, or null.
    - `status` (one of: "found" | "not_found" | "pending" | "not_attempted" | "not_requested" | "failed" | "unconfirmed", required) — `found`: a value was found. `not_found`: the lookup finished without a value. A repeat request returns the saved result without another charge. `pending`: the lookup is running. Poll `lookupId`. `not_attempted`: the lookup is unavailable or the supplied details are insufficient. Nothing was searched or charged. For phone lookups, include `linkedinUrl`. `not_requested`: absent from `fields`. `failed`: this field failed. Read `error` for the reason and retry instructions. Nothing was charged for this field. If every requested field fails, the whole call returns an error. `unconfirmed` (email only): an address was found, but `identityConfidence` is `conflict`, so it may belong to someone else. `value` still holds it; have a human check it before sending. Branch on this field: `found` is never given for a `conflict` address.
    - `foundAt` (date-time | null, required) — When the lookup ran. For a saved result, this is the time of the original lookup.
    - `source` (one of: "lookup" | "saved" | null, required) — `lookup`: this call started a lookup or returned one that is still running. `saved`: this call returned a saved result without another charge. Null when `not_requested`.
    - `lookupId` (uuid | null, required) — Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`.
    - `error` (object | null, required) — Set only when `status` is `failed`: the `code`, `retry` and `message` the call would have answered with had this been the only field requested. Branch on it exactly as you would on an error response.
    - `identityConfidence` (one of: "match" | "conflict" | "unknown" | null, required) — Whether a FOUND address looks like THIS person's. Two comparisons: the address's local-part against the `fullName` you sent, and the address's domain against the `company.website` you sent. The name comparison always runs, because `fullName` is required. The domain comparison runs only when you send a website — `person.company.website` echoes back the domain supplied to that comparison, or null when there was none. Either comparison can have no usable evidence. Apply these rules in order: `conflict` when either comparison disagrees, even if the other agrees; otherwise `unknown` for a similar-looking company domain or a sibling government domain, even when the name matches; otherwise `match` when at least one comparison agrees; otherwise `unknown`. A free-mail address or an absent or unusable employer website provides no employer evidence, so the name alone can still produce `match` or `conflict`. Include the known employer website even when you send a profile. `match` does not prove mailbox ownership or current employment. A `conflict` turns `status` into `unconfirmed`: have a human look before sending; this field does not block sending. Null when no address was found.
  - `phone` (object, required) — Phone lookups can take several minutes. For `pending`, poll `lookupId`. `not_attempted` means the lookup is unavailable or the supplied details are insufficient. Include `linkedinUrl` when known. No lookup starts and nothing is charged for `not_attempted`.
    - `value` (string | null, required) — What was found, or null.
    - `status` (one of: "found" | "not_found" | "pending" | "not_attempted" | "not_requested" | "failed", required) — `found`: a value was found. `not_found`: the lookup finished without a value. A repeat request returns the saved result without another charge. `pending`: the lookup is running. Poll `lookupId`. `not_attempted`: the lookup is unavailable or the supplied details are insufficient. Nothing was searched or charged. For phone lookups, include `linkedinUrl`. `not_requested`: absent from `fields`. `failed`: this field failed. Read `error` for the reason and retry instructions. Nothing was charged for this field. If every requested field fails, the whole call returns an error.
    - `foundAt` (date-time | null, required) — When the lookup ran. For a saved result, this is the time of the original lookup.
    - `source` (one of: "lookup" | "saved" | null, required) — `lookup`: this call started a lookup or returned one that is still running. `saved`: this call returned a saved result without another charge. Null when `not_requested`.
    - `lookupId` (uuid | null, required) — Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`.
    - `error` (object | null, required) — Set only when `status` is `failed`: the `code`, `retry` and `message` the call would have answered with had this been the only field requested. Branch on it exactly as you would on an error response.
  - `quota` (object, optional) — Email and phone lookup usage and any active allowances AFTER this call, counted separately. `limit` and `remaining` are `null` when no lookup cap applies. If `remaining` is a number, size your next request by it: people past that allowance are skipped. Omitted only when the allowance could not be read; `GET /v1/whoami` (MCP: trayo_whoami) reports the same numbers.
    - `email` (object, required)
    - `phone` (object, required)
- `400` → ErrorDto: Neither `linkedinUrl` nor `company.website`; a `linkedinUrl` that is not a personal profile URL; a missing or empty `fullName`; or an unknown field. `param` names it.

`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` → ErrorDto: 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` → ErrorDto: `lookup_limit_reached` (`retry: do_not_retry`) — This workspace cannot run this contact lookup: the plan's email or phone lookup limit is reached, or the plan does not include that kind of lookup. `message` says which and quotes the limit. Retrying never helps and neither does changing the request — the plan has to change. Nothing was started and nothing was charged.
- `403` → string: 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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `contact_lookup_failed` (`retry: retry_later`) — The email lookup could not be completed: it failed or ran past its time budget. Nothing was charged, nothing was stored, and no allowance was consumed. Send the same request again shortly. A lookup that RAN and found nothing is a `200` with `status: "not_found"`, not this.

`lookup_start_failed` (`retry: retry_later`) — The contact lookup passed every check but could not be started. No lookup is running for it, and a retry of the same request is not charged twice. Send it again shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/contacts/lookup" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fullName":"<fullName>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/contacts/lookup", headers=HEADERS, json={"fullName":"<fullName>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/contacts/lookups/{lookupId}

`getContactLookup` — Collect a lookup that was still running

**Required permissions:** `people:enrich_phone`.

The state of one lookup started by `POST /v1/contacts/lookup` — in practice a phone. `status` is `pending` until the lookup finishes, then `found`, `not_found`, or `failed` and it stays that way. Poll every 10 to 15 seconds. A phone lookup typically finishes within two minutes, but can take up to four. Attempts expire two hours after acceptance. On `failed`, stop polling this ID; the nested error explains why. A new `POST /v1/contacts/lookup` may be tried later, with normal charges and no automatic refund. Polling is free.

This response does not include the person. Keep a note of who each `lookupId` refers to.

Trayo saves a `found` or `not_found` answer for repeat requests, so re-sending the original `POST` after it settles returns it with `source: "saved"` and no charge — either way of collecting it works.

**Parameters**

- `lookupId` (path, uuid, required) — From a `POST /v1/contacts/lookup` response.

**Responses**

- `200` → ContactLookupStatusDto: The lookup, pending or final.
  - `lookupId` (uuid, required)
  - `field` (one of: "email" | "phone", required) — Which value this lookup is for.
  - `value` (string | null, required)
  - `status` (one of: "found" | "not_found" | "pending" | "failed", required) — `pending` means still running — poll every 10 to 15 seconds. `failed` is terminal: stop polling this ID.
  - `startedAt` (date-time, required) — When the lookup was accepted.
  - `foundAt` (date-time | null, required) — When it finished. Null while `pending`.
  - `error` (object, optional) — Present on failed: stop polling this ID; a new POST may be tried later and is billed normally.
    - `code` (one of: "phone_lookup_failed" | "phone_lookup_expired", required)
    - `retry` (one of: "fix_input" | "retry_later" | "do_not_retry", required)
    - `message` (string, required)
- `400` → ErrorDto: `lookupId` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `contact_lookup_not_found` (`retry: do_not_retry`) — No such `lookupId` in your workspace. Another workspace's lookup is indistinguishable from one that never existed. The id comes from a `POST /v1/contacts/lookup` response.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {lookupId} with the real value.
curl "$TRAYO_API_URL/v1/contacts/lookups/{lookupId}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

lookup_id = "..."  # the {lookupId} in the path
r = requests.get(f"{BASE}/v1/contacts/lookups/{lookup_id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Lists

Working sets you curate — pinned people and whole-account entries, each with provenance. The bridge from what you found to what you act on, and the only way a set outlives the session that built it.

### GET /v1/lists

`listLists` — List lists

**Required permissions:** `people:read`.

A page of your lists, most recently created first. PAGINATES: follow `nextCursor` until `hasMore` is false.

Lists are scoped to the USER your key acts as, matching the app: you see the lists this key created, and they appear in the Trayo app under that user. Another user's list in the same workspace is indistinguishable from a non-existent one.

**Parameters**

- `q` (query, string, optional) — Case-insensitive substring match against `name` only.
- `updatedSince` (query, string, optional) — Send a plain date — `2026-06-07`, read as midnight UTC — or a full ISO-8601 timestamp — `2026-06-07T14:30:00Z` — when you need the time of day. **Relative words are not accepted:** `yesterday`, `last-quarter`, `7d` and anything else that has to be resolved against "now" is rejected with a 400 (`invalid_date`). Compute the date yourself and send it. Only lists whose `updatedAt` is at or after this value. Renaming a list moves `updatedAt`; adding or removing members does not.
- `limit` (query, integer, optional) — Page size, default 25, max 100.
- `cursor` (query, string, optional) — A previous page's `nextCursor`, verbatim.

**Responses**

- `200` → ListListDto: 
  - `data` (object[], required) — Lists, most recently created first.
    - `id` (uuid, required)
    - `name` (string, required)
    - `origin` (one of: "saved" | "import" | "play", required) — `saved` for anything created here. `import` and `play` are app-created lists.
    - `memberCount` (integer, required) — Live, non-excluded members of both kinds.
    - `composition` (object, required) — `memberCount`, split by kind.
    - `lastOutreachAt` (date-time | null, required) — When the app last launched outreach from this list. Null until it has.
    - `createdAt` (date-time, required)
    - `updatedAt` (date-time, required)
  - `hasMore` (boolean, required) — Another page exists. When `false` you have reached the end — stop requesting pages.
  - `nextCursor` (string | null, required) — Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.

`invalid_date` (`retry: fix_input`) — A date filter (`updatedSince`, `since`, `detectedSince`) did not parse. Send a plain date (`2026-06-07`, read as midnight UTC) or a full ISO-8601 timestamp (`2026-06-07T14:30:00Z`). Relative words such as `yesterday` or `last-quarter` are not accepted. `param` names the field.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/lists" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/lists", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/lists

`createList` — Create a list

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

**A list is where the set you are working lives between calls.** Everything else in this API is a record the workspace holds; a list is YOUR grouping of those records — the 25 accounts for this week, the 40 people going to a campaign. Create it, add members with `POST /v1/lists/{id}/members`, and hand the list (not your session) to whatever acts on it.

**Do not mark a working set any other way.** Overloading `starred`, writing a marker into a person's `title`, or keeping the set only in your own context are all things previous callers did before this resource existed, and all three lose the set or corrupt a record. This is the intended place.

The response has `memberCount: 0` — members are a second call. Lists are scoped to the USER your key acts as, matching the app: you see the lists this key created, and they appear in the Trayo app under that user. Another user's list in the same workspace is indistinguishable from a non-existent one.

**Request body** (`application/json`)

- `name` (string, required) — Human-readable name, shown in the Trayo app. Not unique — two lists may share a name, and the `id` is the identity.

**Responses**

- `201` → ListDto: List created, empty.
  - `id` (uuid, required)
  - `name` (string, required)
  - `origin` (one of: "saved" | "import" | "play", required) — `saved` for anything created here. `import` and `play` are app-created lists.
  - `memberCount` (integer, required) — Live, non-excluded members of both kinds.
  - `composition` (object, required) — `memberCount`, split by kind.
    - `accounts` (integer, required) — Whole-account entries.
    - `people` (integer, required) — Pinned people.
  - `lastOutreachAt` (date-time | null, required) — When the app last launched outreach from this list. Null until it has.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/lists" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"<name>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/lists", headers=HEADERS, json={"name":"<name>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/lists/{id}

`getList` — Get a list

**Required permissions:** `people:read`.

The list with its live counts. Members themselves are on `GET /v1/lists/{id}/members`, paginated.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → ListDto: 
  - `id` (uuid, required)
  - `name` (string, required)
  - `origin` (one of: "saved" | "import" | "play", required) — `saved` for anything created here. `import` and `play` are app-created lists.
  - `memberCount` (integer, required) — Live, non-excluded members of both kinds.
  - `composition` (object, required) — `memberCount`, split by kind.
    - `accounts` (integer, required) — Whole-account entries.
    - `people` (integer, required) — Pinned people.
  - `lastOutreachAt` (date-time | null, required) — When the app last launched outreach from this list. Null until it has.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl "$TRAYO_API_URL/v1/lists/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.get(f"{BASE}/v1/lists/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### PATCH /v1/lists/{id}

`updateList` — Rename a list

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

`name` is the only mutable field. Membership changes go through the `/members` routes, which is what keeps them append-only and safe to retry.

**Parameters**

- `id` (path, uuid, required)

**Request body** (`application/json`)

- `name` (string, required) — Human-readable name, shown in the Trayo app. Not unique — two lists may share a name, and the `id` is the identity.

**Responses**

- `200` → ListDto: 
  - `id` (uuid, required)
  - `name` (string, required)
  - `origin` (one of: "saved" | "import" | "play", required) — `saved` for anything created here. `import` and `play` are app-created lists.
  - `memberCount` (integer, required) — Live, non-excluded members of both kinds.
  - `composition` (object, required) — `memberCount`, split by kind.
    - `accounts` (integer, required) — Whole-account entries.
    - `people` (integer, required) — Pinned people.
  - `lastOutreachAt` (date-time | null, required) — When the app last launched outreach from this list. Null until it has.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X PATCH "$TRAYO_API_URL/v1/lists/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"<name>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.patch(f"{BASE}/v1/lists/{id}", headers=HEADERS, json={"name":"<name>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### DELETE /v1/lists/{id}

`deleteList` — Delete a list

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

Hard delete: the list and its membership rows are removed. The people and accounts it pointed at are untouched — membership is a reference, not ownership. Returns 200 with the list as it was.

Refused with `list_in_use` while an outreach play in the Trayo app references the list; that reference is not addressable here.

**Parameters**

- `id` (path, uuid, required)

**Responses**

- `200` → ListDto: The list as it existed immediately before deletion.
  - `id` (uuid, required)
  - `name` (string, required)
  - `origin` (one of: "saved" | "import" | "play", required) — `saved` for anything created here. `import` and `play` are app-created lists.
  - `memberCount` (integer, required) — Live, non-excluded members of both kinds.
  - `composition` (object, required) — `memberCount`, split by kind.
    - `accounts` (integer, required) — Whole-account entries.
    - `people` (integer, required) — Pinned people.
  - `lastOutreachAt` (date-time | null, required) — When the app last launched outreach from this list. Null until it has.
  - `createdAt` (date-time, required)
  - `updatedAt` (date-time, required)
- `400` → ErrorDto: `id` is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.
- `409` → ErrorDto: `list_in_use` (`retry: do_not_retry`) — An outreach play still references this list, so it cannot be deleted. Plays are managed in the Trayo app, not here — remove the reference there, then delete. Repeating this call unchanged will keep failing.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X DELETE "$TRAYO_API_URL/v1/lists/{id}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.delete(f"{BASE}/v1/lists/{id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/lists/{id}/members

`listListMembers` — List members

**Required permissions:** `people:read`.

A page of the list's live members, most recently added first. PAGINATES: follow `nextCursor` until `hasMore` is false.

Each row is a MEMBERSHIP: its `id` is what `DELETE …/members/{memberId}` takes, and `personId` / `accountId` say what it points at. Fetch the person or account itself from its own route.

Rows whose person or account has since been deleted, and rows the app has marked `excluded`, are omitted.

**Parameters**

- `id` (path, uuid, required)
- `kind` (query, string, optional) — Only members of this kind. Omit for both.
- `limit` (query, integer, optional) — Page size, default 25, max 100.
- `cursor` (query, string, optional) — A previous page's `nextCursor`, verbatim.

**Responses**

- `200` → ListMemberListDto: 
  - `data` (object[], required) — Members, most recently added first.
    - `id` (uuid, required)
    - `listId` (uuid, required)
    - `kind` (one of: "person" | "account", required)
    - `personId` (string | null, required) — Set on `kind: person`. Null on an account entry.
    - `accountId` (string | null, required) — On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null.
    - `via` (one of: "manual" | "account" | "signal" | "find" | "import", required)
    - `eventId` (string | null, required)
    - `per` (integer | null, required) — Account entries only: how many contacts the app resolves for this account (1 = best contact). Members added here get 1. Null on person rows.
    - `excluded` (boolean, required) — A negative membership set in the app — "not this person" against a re-resolving account entry. Read-only here; excluded rows are not counted in `memberCount` and are omitted from `GET …/members`.
    - `createdAt` (date-time, required)
    - `person` (object | null, optional) — On the members listing: who the person is. Null on an account entry.
    - `account` (object | null, optional) — On the members listing: the account the row names, as `accountId` does (the member on `kind: account`, where the person worked on `kind: person`). Null when `accountId` is, or when that account has been removed.
  - `hasMore` (boolean, required) — Another page exists. When `false` you have reached the end — stop requesting pages.
  - `nextCursor` (string | null, required) — Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false.
- `400` → ErrorDto: `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.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl "$TRAYO_API_URL/v1/lists/{id}/members" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.get(f"{BASE}/v1/lists/{id}/members", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/lists/{id}/members

`addListMembers` — Add members

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

Add people and/or whole accounts, up to 200 per call. Each row is either `{ personId }` (pin a person) or `{ accountId }` (add the whole account as a dynamic entry) — the kind is derived from which id you send. Set `via` to say how they got here: `find` for a `POST /v1/find` result, `signal` when an event suggested them, `import` for a bulk load, `manual` (default) otherwise.

**Partial success, and duplicates are skips.** A row that is already on the list — from this call or an earlier one — lands in `skipped` with a reason, not in an error, so the same batch can be sent twice without doubling anything. Read `skipped` every time; `200` means the call ran, not that every row was added.

The typical shape: `POST /v1/find` → `POST /v1/accounts/batch` (or `/v1/people/batch`) → this route with the ids that came back and `via: "find"`.

**Parameters**

- `id` (path, uuid, required)

**Request body** (`application/json`)

- `members` (object[], required) — 1 to 200 members. Over the cap is an error, not a clamp.
  - `personId` (uuid, optional) — Pin this person (a `GET /v1/people` id). Mutually exclusive with `accountId`.
  - `accountId` (uuid, optional) — Add this whole account (a `GET /v1/accounts` id) as a dynamic entry — "Acme, best contact" — whose concrete people the app resolves from the live roster whenever the list is opened. Mutually exclusive with `personId`.
  - `via` (one of: "manual" | "find" | "signal" | "import", optional) — How this member got here, recorded on the row and shown in the app. `find` for a `POST /v1/find` result, `signal` when an event suggested them, `import` for a bulk load, `manual` (the default) otherwise. This is provenance for humans reading the list later — set it honestly.
  - `eventId` (uuid, optional) — The event (`GET /v1/events` id) that suggested this member, if one did. Verified in your workspace like the member itself: an unknown id skips the row, and its `reason` says the event was not found.

**Responses**

- `200` → AddListMembersResultDto: The request was processed. Check `skipped` — a `200` does not mean every row landed.
  - `added` (object[], required) — The rows that were added, in submission order.
    - `id` (uuid, required)
    - `listId` (uuid, required)
    - `kind` (one of: "person" | "account", required)
    - `personId` (string | null, required) — Set on `kind: person`. Null on an account entry.
    - `accountId` (string | null, required) — On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null.
    - `via` (one of: "manual" | "account" | "signal" | "find" | "import", required)
    - `eventId` (string | null, required)
    - `per` (integer | null, required) — Account entries only: how many contacts the app resolves for this account (1 = best contact). Members added here get 1. Null on person rows.
    - `excluded` (boolean, required) — A negative membership set in the app — "not this person" against a re-resolving account entry. Read-only here; excluded rows are not counted in `memberCount` and are omitted from `GET …/members`.
    - `createdAt` (date-time, required)
    - `person` (object | null, optional) — On the members listing: who the person is. Null on an account entry.
    - `account` (object | null, optional) — On the members listing: the account the row names, as `accountId` does (the member on `kind: account`, where the person worked on `kind: person`). Null when `accountId` is, or when that account has been removed.
  - `skipped` (object[], required) — Rows that were NOT added: malformed, unknown or another workspace's id, or ALREADY A MEMBER. Always inspect this — a `200` means the call succeeded, not that every row landed.
    - `index` (integer, required) — 0-based position in the submitted `members`.
    - `reason` (string, required) — Why this row was not added.
- `400` → ErrorDto: The ENVELOPE was wrong: `members` missing, not an array, empty, or over 200 items. A bad 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` → ErrorDto: 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` → string: 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` → ErrorDto: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id} with the real value.
curl -X POST "$TRAYO_API_URL/v1/lists/{id}/members" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"members":[{"personId":"11111111-1111-4111-8111-111111111111"}]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
r = requests.post(f"{BASE}/v1/lists/{id}/members", headers=HEADERS, json={"members":[{"personId":"11111111-1111-4111-8111-111111111111"}]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### DELETE /v1/lists/{id}/members/{memberId}

`removeListMember` — Remove a member

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

Removes the membership row. The person or account is untouched. `memberId` is the row's `id` from `GET /v1/lists/{id}/members`, not the person's or account's id. Returns 200 with the row as it was.

**Parameters**

- `id` (path, uuid, required)
- `memberId` (path, uuid, required)

**Responses**

- `200` → ListMemberDto: The membership as it existed before removal.
  - `id` (uuid, required)
  - `listId` (uuid, required)
  - `kind` (one of: "person" | "account", required)
  - `personId` (string | null, required) — Set on `kind: person`. Null on an account entry.
  - `accountId` (string | null, required) — On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null.
  - `via` (one of: "manual" | "account" | "signal" | "find" | "import", required)
  - `eventId` (string | null, required)
  - `per` (integer | null, required) — Account entries only: how many contacts the app resolves for this account (1 = best contact). Members added here get 1. Null on person rows.
  - `excluded` (boolean, required) — A negative membership set in the app — "not this person" against a re-resolving account entry. Read-only here; excluded rows are not counted in `memberCount` and are omitted from `GET …/members`.
  - `createdAt` (date-time, required)
  - `person` (object | null, optional) — On the members listing: who the person is. Null on an account entry.
    - `fullName` (string, required)
    - `title` (string | null, required)
    - `company` (string | null, required) — The person's current account name, when they have one.
  - `account` (object | null, optional) — On the members listing: the account the row names, as `accountId` does (the member on `kind: account`, where the person worked on `kind: person`). Null when `accountId` is, or when that account has been removed.
    - `name` (string, required)
    - `website` (string, required) — The account's website URL.
- `400` → ErrorDto: A path parameter is not a UUID.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.

`list_member_not_found` (`retry: do_not_retry`) — No such member on this list. `memberId` is the MEMBERSHIP row's `id` from `GET /v1/lists/{id}/members` — not the person's or account's id.
- `429` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
# Replace {id}, {memberId} with the real value.
curl -X DELETE "$TRAYO_API_URL/v1/lists/{id}/members/{memberId}" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

id = "..."  # the {id} in the path
member_id = "..."  # the {memberId} in the path
r = requests.delete(f"{BASE}/v1/lists/{id}/members/{member_id}", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Find

Search for companies and people that are NOT yet in your workspace — by a sentence, by exact filters, or both. Every other resource here operates on records you already have; this is the only entry point that brings new ones in. It never writes — results are shaped to be posted to `POST /v1/accounts/batch` and `POST /v1/people/batch`.

### POST /v1/find

`find` — Find companies or people not yet in your workspace

**Required permissions:** `body.target` equals `people`: `people:read`; `body.target` equals `companies`: `accounts:read`. Other or missing values: no additional scopes; request validation still applies.

Search for companies and people you do not already have. It never writes: you get candidates, and you import the ones you want.

**For explicit criteria, use `mode: direct` with `filters`.** Reads stored company and people records with no sentence interpretation or external search. `query` is rejected. Each call returns one page of at most 100 records; follow `nextCursor` to continue. Company records without websites are kept and can be read again by `filters.companyIds`. A direct companies search can combine company criteria with `filters.title` to return companies with a matching current employee, once per company. People results include their matching current company. A company reaching `perCompany` sets `truncated: true` with a note because additional matching people may exist. Omitting mode preserves the usual search behavior below.

**Say what you want in one of two ways, or both.**

- `query` — a sentence. Trayo interprets it and ranks results by relevance. `interpreted` in the response tells you what it read out of the sentence, in the same `filters` shape you can send back. One ranked page of at most 50; no cursor.
- `filters` — exact attributes: industry, headcount, headquarters, founded year, technologies, legal form, and for people a title. Applied exactly as sent, never interpreted, never fail-open: a company with no value for a filtered attribute is excluded. Paginated. A companies search without a title can use only country, founded year, technology or company type: it checks a bounded set of the largest companies and reports `led_by_headcount`, so its matches are not exhaustive. A people search or a company search with a title needs named companies or `industries`, `hq.cities`, `hq.states` or a `headcount` band. Read `GET /v1/find/industries` before your first industry filter, because the values are a closed taxonomy.
- both — the sentence ranks, the filters decide. Rows Trayo cannot check are dropped.

**`target: people` returns people with their current company.** `"founders at software companies"` is `{"target": "people", "filters": {"industries": ["software development"], "title": {"any": ["founder"]}}}`. Company filters describe the employer; `title` describes the person. With a `query`, a people search takes `title` only.

**Searching inside companies you already know.** Name them instead of describing them: `filters.companyWebsites` takes their websites (`["stripe.com", "https://www.acme.example"]`), the form you already have without asking this API for anything. It replaces every other company filter, and takes no `query` beside it. (`filters.companyIds`, which takes an `id` a previous search returned, is the older form of the same thing and replaces it in turn; websites are what to reach for.) Each website resolves to at most one company, and `resolvedWebsites` in the response says which: several company profiles can share a domain, so a website resolves only to a profile whose name matches the domain; check it before trusting the page. A website that does not resolve is left out of the search and listed in `unresolvedWebsites`, never dropped without saying so: the page covers only the websites in `resolvedWebsites`. `reason: "not_found"` means no company on that domain: fix the spelling, send the company's own domain, or describe that company with filters in a separate search. `reason: "uncertain"` means the domain carries several possible companies and none clearly owns it; its `alternatives` list them (id, name, headcount). Show them to the user, or search again with `filters.companyIds` set to the right alternative's id. When no website resolves at all, the answer is a `404 company_not_found` naming them.

**Reading the response.** `data` holds one row shape per target. `hasMore`/`nextCursor` page a filters-only search. `truncated` means more matched than this search can show — narrow, do not page deeper. `known: false` on a company means the sentence search found something Trayo does not hold. `state: alreadyAdded` means it is in your workspace; for "find N new companies", send `filters.excludeWorkspace: true` and those rows are left out, so the page fills with companies you do not have.

**Before re-running a thin search, read `interpreted`, `truncated` and `degraded`.** They say whether your criteria were enforced, whether the set is larger than reachable, and whether the search was incomplete — and a search that ran counts against the quota whether or not it was useful. Outside direct mode, every company row carries a `url`: rows with no website are dropped before the page is filled and counted in `droppedUnaddressable`, because `POST /v1/accounts` could not import them.

**Importing.** Companies: `POST /v1/accounts/batch` with `{name, url}` from each row (`url` is ready to send). People: `POST /v1/people/batch` with `{fullName, title, linkedinUrl}`; the company links by website when known. Contact details are a separate step: `POST /v1/contacts/lookup` with the `linkedinUrl`.

`target: companies` needs the `accounts:read` scope, `target: people` needs `people:read`. A search with a `query` draws on the workspace's search quota when it runs; a filters-only search does not.

**Parameters**

- `idempotency-key` (header, string, optional) — For searches with a query, replay the saved response without repeating the search. Reuse only with the same request body. Filters-only searches do not use this key.

**Request body** (`application/json`)

- `target` (one of: "companies" | "people", required) — What to return: `companies` or `people`. People are returned with their current company.
- `mode` (string, optional) — Use `direct` for a bounded search of stored company and people data with exact `filters`. No sentence interpretation or external search. Requires filters, rejects query, and returns one page of at most 100 records. Company records without websites are kept and can be searched again by companyIds. A direct title search checks at most 30 companies per request in `sort` order (largest by default, smallest with `smallest_first`): send at most 30 companyIds or companyWebsites at a time. Omit mode to retain the usual query or filters search.
- `query` (string, optional) — A sentence describing what to find. Trayo interprets it and ranks results by relevance; `interpreted` in the response says what it read. Omit it to search by `filters` alone, which is exact and paginated. Send both to rank by the sentence and keep only rows that pass the filters. On a people search, the company filters (`companyWebsites`, `companyIds`, `industries`, `headcount`, `hq` and the rest) are checked against each ranked person's current employer, which the row then carries: `companyWebsites` must be that employer's own website. A person whose employer fails them, or whose current employer Trayo cannot identify, is left out. The page is not refilled, and a `query_page_filtered` note counts who was left out. For a filters-only people search, omit `query` and use `mode: "direct"` with `filters.title` and named companies or an indexed company filter such as `industries` or `headcount`; country, founded-year, technology and company-type filters alone are insufficient beside a title. A title-free companies search can use those filters alone, checking a bounded set of the largest companies (`led_by_headcount`).
- `filters` (object, optional) — Exact attributes to match. Never interpreted, never fail-open. Alone, `industries`, `hq.cities`, `hq.states` or `headcount` select the set and the rest narrow it; a companies search without `title` may send only a founded year, company type, technology or place filter, then checks the largest companies (`led_by_headcount` in `notes`). With a `query`, any combination narrows the ranked results.
  - `industries` (string[], optional) — Exact values from the industry taxonomy — list them with `GET /v1/find/industries?contains=software`. "SaaS" is not a value; "software development" is. Case-insensitive, up to 99. Several values are a union. An uncapped page merges the same rows each value returns alone; when `truncated` is true, read the notes before treating the page as complete. When present, industries drive the indexed scan. Exact: a company with no value for this attribute does NOT match.
  - `headcount` (object, optional) — Inclusive employee-count band, e.g. `{"min": 11, "max": 200}`. Either bound may be omitted. Selects a set on its own. In a filters-only search with no company attributes, or beside a title without an indexed company filter, send a saved workspace profile's `buyerProfile.sizeBand` (its max capped at 50,000) beside its mapped industries; with a partial profile, no mapped industry or no profile, send `{"min": 1, "max": 10000}`. Never `{"min": 1}` alone: the largest employers make common titles time out. Beside a title with only a founded year, technology or company type, fit the band to it (founded in recent years: `{"min": 1, "max": 50}`). Without a title, founded year, technology and company type may go alone. With a country and no industries, pin its main cities or states: the band beside a country alone can time out. Exact: a company with no value for this attribute does NOT match.
    - `min` (integer, optional)
    - `max` (integer, optional)
  - `hq` (object, optional) — Where the company is headquartered, not where any person is based. For where a person is based, say it in a people `query`, or use `filters.person` (`target: people`, no `query`).
    - `countries` (string[], optional) — Countries of the headquarters: ISO-2 codes (`["US", "DE"]`) or country names (`["United States", "Germany"]`), any case. A name is read as its ISO-2 code, and `applied` echoes the codes. Applied inside the scan, cheap, any number up to 60. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
    - `regions` (one of: "north_america" | "europe" | "asia_pacific" | "latin_america" | "middle_east" | "africa"[], optional) — Shorthand for a block of countries: `north_america`, `europe`, `asia_pacific`, `latin_america`, `middle_east`, `africa`. Expands to ISO-2 codes and merges with `countries`. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
    - `cities` (string[], optional) — Headquarters city, exact match, case-insensitive (`"san francisco"`). At most 20. Can select a set on its own. A bare city name is a NAME, not a place, and matches it everywhere on earth: `"dublin"` is Ireland and Ohio and California, `"newark"` is New Jersey and California. Pin one by writing `"city|state"`, `"city|state|country"` or `"city||country"` — `"newark|california"`, `"dublin||IE"`, `"richmond|virginia|US"`. The state is its full name and the country an ISO-2 code, each pinned city narrows on its own, and pinned and bare values mix freely. The city part is required: for every company in a state or country, use `states` or `countries`. When a bare value turns out to span more than one place, the response says so in `notes`. Exact: a company with no value for this attribute does NOT match.
    - `states` (string[], optional) — Headquarters state or province, full name, case-insensitive (`"california"`, `"bavaria"`, not `"CA"`). At most 20. Can select a set on its own. There is no US-region shorthand; spell the states out. Applied alongside `cities` as a separate AND, so `cities: ["newark", "dublin"]` with `states: ["california", "ohio"]` admits all four pairings — write the pins into `cities` to avoid that. Exact: a company with no value for this attribute does NOT match.
  - `person` (object, optional) — `target: people` without `query` only. Where the person says they are, from their LinkedIn profile location: not a verified address, and not the employer's headquarters (that is `hq`). `countries`, `states` and `cities` are each any-of, and they AND together. A person with no recorded value for a part you sent does NOT match. `states` and `cities` narrow the people at a set of companies: send them beside `companyWebsites`, `companyIds` or an indexed company filter (`industries`, `hq.cities`, `hq.states`, `headcount`). Applied before `perCompany`, so each company still returns its most senior people who match.
    - `countries` (string[], optional) — Countries the person is in: ISO-2 codes (`["US", "DE"]`) or country names (`["United States", "Germany"]`), any case. A name is read as its ISO-2 code, and `applied` echoes the codes. At most 60.
    - `states` (string[], optional) — State or province the person is in, full name, case-insensitive (`"california"`). A US, Australian or Canadian postal code (`"CA"`, `"NSW"`, `"ON"`) is read as its full name in the person's `countries`; with no country, a code two of them share (`"WA"`) matches each state it names. At most 20.
    - `cities` (string[], optional) — City the person is in, whole name, case-insensitive (`"san diego"`). A person whose profile names only a metro area ("Greater San Diego Area") has no city and does not match one. A city name can exist in several places: send `countries` or `states` beside it. At most 20.
  - `foundedYear` (object, optional) — Inclusive founded-year band, e.g. `{"min": 2018}`. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
    - `min` (integer, optional)
    - `max` (integer, optional)
  - `technologies` (string[], optional) — Technologies detected on the company's public web presence, any of, up to 20. Matched on the name with punctuation and case removed, so `"Amazon Web Services"`, `"aws"` and `"HubSpot"` are all valid. A web-stack scan: good for marketing, analytics and cloud tooling, blind to internal systems such as an ERP. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
  - `companyTypes` (one of: "privately held" | "public company" | "nonprofit" | "educational" | "government agency" | "partnership" | "self-employed" | "self-owned"[], optional) — Legal form as the company describes itself, a closed set: `privately held`, `public company`, `nonprofit`, `educational`, `government agency`, `partnership`, `self-employed`, `self-owned`. `public company` includes wholly-owned subsidiaries of listed groups, not only the listed entity. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
  - `companyIds` (string[], optional) — **Reach for `companyWebsites` instead** — it names the same companies by something you already have. This is the older form of that filter, kept for callers holding ids: exact companies, by the `id` a companies search returned with `known: true`, at most 200. For `target: companies` it is a lookup of exactly those rows; for `target: people` it names whose people to search. Other company filters narrow only these companies (AND). Without `query`, `named_companies_filtered` notes identify companies the attributes exclude. With `query`, the ranked candidates are intersected with these IDs; this does not search every named company. Do not combine with `companyWebsites`.
  - `companyWebsites` (string[], optional) — The same thing as `companyIds`, named by something you already have: each company's own website — at most 100. `stripe.com`, `www.stripe.com` and `https://stripe.com/pricing` all name the same company; the scheme, `www.` and any path are ignored. Without `query`, each website is resolved to exactly one company Trayo holds, and the resolution is reported back in `resolvedWebsites` so you can see which company each one became. Websites that do not resolve are left out of the search and listed in `unresolvedWebsites`, so the answer can cover fewer companies than you named: read that list before you treat a short page as complete. A `not_found` one: check the spelling, send the company's own domain rather than a parent group's, a reseller's or a marketing microsite's, or describe the company with `filters.industries`, `filters.hq` or `filters.headcount` in a separate search. An `uncertain` one: the domain carries several possible companies and none was searched; show its `alternatives` to the user, or search again with `filters.companyIds` set to the right alternative's `id`. Only when NONE of the websites resolves is the answer a `404 company_not_found` naming them. Other company filters narrow only the resolved companies (AND), and `named_companies_filtered` notes identify companies they exclude. On a people search with `query`, websites instead match the ranked people's current employers directly: `resolvedWebsites` and `unresolvedWebsites` are null, and no matching employer yields an empty page rather than `company_not_found`. A `query_page_filtered` note counts people left out; the page is not refilled. Other company attributes also narrow the current employer (AND). Do not combine with `companyIds`.
  - `title` (object, optional) — Current employee title. A filters-only people search requires `title.any` or `title.all`, even with `companyIds` or `companyWebsites`; no-title named-company sampling is unsupported. In `mode: direct`, a companies search may also use it: return each company with at least one matching current employee. Pair it with companyIds, companyWebsites or an indexed company filter; when the request names none, take them from the workspace profile (see `headcount`). A title is searched only at the first companies in `sort` order (in `mode: direct`, at most 30: largest first by default, the smallest with `smallest_first`) unless `notes` say `title_matched_first`; `company_fan_out_limit_reached` says more companies matched. Never report it as every company.
    - `any` (string[], optional) — Title terms, ANY of which must appear in the person's current title. A multi-word term matches as a phrase, with at most one word between the words: `"vp finance"` matches "VP Finance" and "VP of Finance", not "VP of Engineering, Finance partner". Punctuation and case are ignored: `"founder"` matches "Co-Founder & CEO". A single common word matches across every function (`"automation"` finds manufacturing and procurement automation too) — prefer two-word terms and use `exclude`. An acronym is matched LITERALLY and expands to nothing: `"cto"` does not match "Chief Technology Officer", and the same role is written both ways inside one company, so send both forms — `["cto", "chief technology officer"]`. At named companies (`companyIds`, `companyWebsites`) every term is searched again at each company, so a long list there can run out of time: send the forms the user means, not every variant.
    - `all` (string[], optional) — Title terms that must ALL appear. Combine with `any` freely.
    - `exclude` (string[], optional) — Title terms that disqualify a person (`["assistant", "intern"]`).
  - `excludeWorkspace` (boolean, optional) — `target: companies` only. Default false. When true, companies already in your workspace are left out of `data` instead of coming back as `state: alreadyAdded`, so the page fills with companies you do not have. Use this for "find N new companies". "Already in your workspace" is decided exactly as `state` is. It is not a filter on its own: a filters-only search still needs a company filter. On a filters-only search a page can come back shorter than `limit` with `hasMore: true` when most matches were already yours — keep paging with `nextCursor`.
- `sort` (one of: "largest_first" | "smallest_first", optional) — Filters-only. Page order by company headcount: `largest_first` (the default) or `smallest_first`. `smallest_first` needs `filters.headcount.min` of at least 1, because companies with no recorded headcount would otherwise come first. A search whose title is rare is ordered by company id instead and ignores `sort`; its pages carry a `title_matched_first` note. A search with a `query` is ranked by relevance and takes no `sort`.
- `perCompany` (integer, optional) — `target: people`, filters-only. People read per company before paging, most senior title first. Default 4, at most 25. Keeps one large employer from filling every page.
- `limit` (integer, optional) — Page size. Default 25, at most 100. A search with a `query` returns at most 50.
- `cursor` (string, optional) — The `nextCursor` from the previous page, sent back verbatim WITH THE SAME REQUEST. Filters-only searches page; a search with a `query` returns one ranked page and rejects a cursor.

**Responses**

- `200` → FindResponseDto: The search ran. An empty `data` array is a successful search that matched nothing — check `truncated`, `degraded` and `interpreted` before retrying.
  - `target` (one of: "companies" | "people", required)
  - `data` (object[] | object[], required) — Companies or people, per `target`. Filters-only pages are ordered by company headcount (`sort`), and people within a company most senior first — except a search whose title is read first, which is ordered by company id and says so in `notes` (`title_matched_first`, or `title_matches_sampled` for a page checked against a sample of the title's roles). An empty array is a successful search that matched nothing, unless `truncated` is true.
  - `hasMore` (boolean, required) — A next page exists. People pages fill company by company, so a page can end before every matched company has been reached — keep paging until false. Always false on a sentence search, which returns one ranked page.
  - `nextCursor` (string | null, required)
  - `truncated` (boolean, required) — The result is or may be incomplete. On a sentence search: more candidates than the page. On a filters-only search: the scan reached its cap, possibly even on a full page, so the pages you can reach are only the companies reached within the cap, not all of the set — `notes` says when that cap was the per-value budget of a multi-value filter, and which values ran out. Either way the answer is to narrow, not to page deeper. In direct people searches, also true when an employer reached perCompany; additional matches may exist there, and raising perCompany can reveal them. Also true when the page was checked against a sample of the title's roles (`title_matches_sampled`).
  - `degraded` (boolean, required) — Part of this sentence search did not answer, so a thin result may not mean a genuine absence of matches. Always false on a filters-only search.
  - `droppedUnaddressable` (integer, required) — Company rows this search found and then dropped for carrying neither a website nor a `url`. `POST /v1/accounts` would accept such a row — `url` is optional there — but deduplication keys on the website, and a sentence match with no website at all is overwhelmingly a directory, a job board or an institution rather than a company. They are dropped on that quality ground, so every row left in `data` can be acted on. On a search with a `query` they are dropped BEFORE the page is filled, so the page still reaches its size; on a filters-only search the page has already been read at its size, so a dropped row shortens it and `hasMore` is unaffected. A high number against a thin `data` means the sentence matched directories, job boards and institutions rather than companies — name the kind of company you want. Always 0 for `target: people`, whose rows are addressed by `linkedinUrl`, and in mode: direct, which keeps company records without websites and identifies them by id.
  - `notes` (object[], required) — Everything about this answer that is not simply "here are the matches": a filter whose values were not all scanned to the end, a bare city name that turned out to mean several places, a sentence search that came back `degraded` because a data source it depends on did not answer. Empty on a search with nothing to report, which is the usual case. Read it before treating a short result as an absence of matches: it is where a short page says it was cut off, or thinned by an outage, rather than that nothing matched.
    - `code` (one of: "filter_values_truncated" | "city_spans_multiple_places" | "search_degraded" | "per_company_limit_reached" | "title_matched_first" | "companies_searched" | "title_matches_sampled" | "search_time_limit_reached" | "residual_scan_limit_reached" | "company_fan_out_limit_reached" | "named_companies_filtered" | "led_by_headcount" | "query_page_filtered" | "query_page_place_filtered" | "query_place_not_applied" | "headcount_capped" | "website_of_another_company" | "hq_country_contradicted" | "funding_filter_unapplied", required) — What the search could not do exactly. `filter_values_truncated`: the scan budget is shared between the values of one filter and ran out, so the result may omit matches — split the request, one value per call, and merge the pages. `city_spans_multiple_places`: a bare city name in `hq.cities` matched more than one state or country on this page — pin it (`"newark|california"`) if you meant one. `per_company_limit_reached`: a direct people search returned the perCompany cap for at least one employer; additional matching people may exist there. `title_matched_first`: the title was rare, so every current role it matches was read and checked against the company filters — paging to the end returns every match, at most `perCompany` per company — and pages are ordered by company id, not headcount (`sort` does not apply). `companies_searched`: the request walked past empty chunks and searched up to the number of companies in `values[0]`; continue with `nextCursor` to search the remaining employers. `title_matches_sampled`: the title matched more current roles than one search reads, so the page was checked against the roles it did read, a sample rather than every match; `truncated` is true and there is no next page. Fewer or more specific title terms read every match. `search_time_limit_reached`: the time limit stopped a people search after some companies; the returned matches are partial, there is no next page, and values lists the remaining company IDs. `residual_scan_limit_reached`: the named filter (founded year, technologies or company type; also country beside `led_by_headcount`, and a city, state or country beside another place or industry filter) is checked company by company, and the search stopped after checking the first companies selected by other filters in `sort` order; add industries, a headcount band or cities to check the companies you want. `company_fan_out_limit_reached`: titles were searched only at the first companies that passed the company filters in `sort` order; narrow those filters, or send fewer companies per request. `named_companies_filtered`: company attributes excluded one or more explicitly named companies; the note identifies them, while `applied` shows the filters that were enforced. `led_by_headcount`: no filter could select the companies (a companies search with only a founded year, company type, technologies or country), so they were taken largest first and checked one by one, up to a fixed number: every row matches, but these are the largest matches, not all of them; tell the user. `funding_filter_unapplied`: recorded funding rounds were not applied to the sentence; use a news signal. `query_page_filtered`: a people search with a `query` checked each ranked person's current employer against the company filters and left out the people whose employer failed them or could not be identified; the page was not refilled, so it can be short. `mode: "direct"` with `filters.title` and no `query` searches using exact filters instead; it also needs named companies or an indexed company filter such as `industries` or `headcount`. `query_page_place_filtered`: the sentence person place was checked; the note counts matches, nonmatches and unplaced people left out without refilling. `query_place_not_applied`: locations could not be read; the named person place was NOT applied and the ranked page was kept. `headcount_capped`: some companies record an employee count far above the size band they declare (often people who list a marketplace or a placeholder as their employer). Their `headcount` is the top of that band; a search in headcount order, or below `headcount.min` once capped, left them out, so a page can be shorter than `limit`. `detail` names them. `website_of_another_company`: a companies search in headcount order left out companies filed under the website of another, larger company (a division, product line, regional office or former name of it, whose headcount is the unit's), so a page can be shorter than `limit`. `detail` names them and that company. `hq_country_contradicted`: a country/region-filtered search left out companies whose country-only HQ is contradicted by their LinkedIn country. `detail` names them and both countries. A page can be shorter than `limit` for it. `search_degraded`: this response carries `degraded: true`. One data source this search depends on was unavailable, slow or switched off for this workspace, so this answer may be thinner than usual — read a short result as possibly incomplete rather than as an absence of matches. A repeat of this request is billed as a new search, and some causes persist for a workspace and degrade every repeat, so use what came back or narrow the request rather than resending it.
    - `filter` (string, optional) — The request field the note is about, e.g. `filters.industries`. Absent when the note is about the response as a whole rather than one field, which is the case for `search_degraded` — group notes by this field only when it is present.
    - `values` (string[], optional) — The values of that field the note applies to, as they were sent. Absent whenever `filter` is.
    - `detail` (string, required) — One sentence naming what was found, for a human reading the response.
  - `applied` (object | null, required) — The `filters` that were enforced on this response, as sent, except that `hq.countries` holds the ISO-2 code each value was read as (`"United States"` comes back as `"US"`). Every row in `data` passed all of them. Null when no filters were sent.
    - `industries` (string[], optional) — Exact values from the industry taxonomy — list them with `GET /v1/find/industries?contains=software`. "SaaS" is not a value; "software development" is. Case-insensitive, up to 99. Several values are a union. An uncapped page merges the same rows each value returns alone; when `truncated` is true, read the notes before treating the page as complete. When present, industries drive the indexed scan. Exact: a company with no value for this attribute does NOT match.
    - `headcount` (object, optional) — Inclusive employee-count band, e.g. `{"min": 11, "max": 200}`. Either bound may be omitted. Selects a set on its own. In a filters-only search with no company attributes, or beside a title without an indexed company filter, send a saved workspace profile's `buyerProfile.sizeBand` (its max capped at 50,000) beside its mapped industries; with a partial profile, no mapped industry or no profile, send `{"min": 1, "max": 10000}`. Never `{"min": 1}` alone: the largest employers make common titles time out. Beside a title with only a founded year, technology or company type, fit the band to it (founded in recent years: `{"min": 1, "max": 50}`). Without a title, founded year, technology and company type may go alone. With a country and no industries, pin its main cities or states: the band beside a country alone can time out. Exact: a company with no value for this attribute does NOT match.
    - `hq` (object, optional) — Where the company is headquartered, not where any person is based. For where a person is based, say it in a people `query`, or use `filters.person` (`target: people`, no `query`).
    - `person` (object, optional) — `target: people` without `query` only. Where the person says they are, from their LinkedIn profile location: not a verified address, and not the employer's headquarters (that is `hq`). `countries`, `states` and `cities` are each any-of, and they AND together. A person with no recorded value for a part you sent does NOT match. `states` and `cities` narrow the people at a set of companies: send them beside `companyWebsites`, `companyIds` or an indexed company filter (`industries`, `hq.cities`, `hq.states`, `headcount`). Applied before `perCompany`, so each company still returns its most senior people who match.
    - `foundedYear` (object, optional) — Inclusive founded-year band, e.g. `{"min": 2018}`. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
    - `technologies` (string[], optional) — Technologies detected on the company's public web presence, any of, up to 20. Matched on the name with punctuation and case removed, so `"Amazon Web Services"`, `"aws"` and `"HubSpot"` are all valid. A web-stack scan: good for marketing, analytics and cloud tooling, blind to internal systems such as an ERP. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
    - `companyTypes` (one of: "privately held" | "public company" | "nonprofit" | "educational" | "government agency" | "partnership" | "self-employed" | "self-owned"[], optional) — Legal form as the company describes itself, a closed set: `privately held`, `public company`, `nonprofit`, `educational`, `government agency`, `partnership`, `self-employed`, `self-owned`. `public company` includes wholly-owned subsidiaries of listed groups, not only the listed entity. Exact: a company with no value for this attribute does NOT match. Narrows a set. Sent with no `industries`, `hq.cities`, `hq.states` or `headcount` and no `query`, a companies search checks companies with known headcount largest first, up to a fixed number, and `notes` say `led_by_headcount`: the largest matches, not all of them. A search with `filters.title` needs one of those four beside it.
    - `companyIds` (string[], optional) — **Reach for `companyWebsites` instead** — it names the same companies by something you already have. This is the older form of that filter, kept for callers holding ids: exact companies, by the `id` a companies search returned with `known: true`, at most 200. For `target: companies` it is a lookup of exactly those rows; for `target: people` it names whose people to search. Other company filters narrow only these companies (AND). Without `query`, `named_companies_filtered` notes identify companies the attributes exclude. With `query`, the ranked candidates are intersected with these IDs; this does not search every named company. Do not combine with `companyWebsites`.
    - `companyWebsites` (string[], optional) — The same thing as `companyIds`, named by something you already have: each company's own website — at most 100. `stripe.com`, `www.stripe.com` and `https://stripe.com/pricing` all name the same company; the scheme, `www.` and any path are ignored. Without `query`, each website is resolved to exactly one company Trayo holds, and the resolution is reported back in `resolvedWebsites` so you can see which company each one became. Websites that do not resolve are left out of the search and listed in `unresolvedWebsites`, so the answer can cover fewer companies than you named: read that list before you treat a short page as complete. A `not_found` one: check the spelling, send the company's own domain rather than a parent group's, a reseller's or a marketing microsite's, or describe the company with `filters.industries`, `filters.hq` or `filters.headcount` in a separate search. An `uncertain` one: the domain carries several possible companies and none was searched; show its `alternatives` to the user, or search again with `filters.companyIds` set to the right alternative's `id`. Only when NONE of the websites resolves is the answer a `404 company_not_found` naming them. Other company filters narrow only the resolved companies (AND), and `named_companies_filtered` notes identify companies they exclude. On a people search with `query`, websites instead match the ranked people's current employers directly: `resolvedWebsites` and `unresolvedWebsites` are null, and no matching employer yields an empty page rather than `company_not_found`. A `query_page_filtered` note counts people left out; the page is not refilled. Other company attributes also narrow the current employer (AND). Do not combine with `companyIds`.
    - `title` (object, optional) — Current employee title. A filters-only people search requires `title.any` or `title.all`, even with `companyIds` or `companyWebsites`; no-title named-company sampling is unsupported. In `mode: direct`, a companies search may also use it: return each company with at least one matching current employee. Pair it with companyIds, companyWebsites or an indexed company filter; when the request names none, take them from the workspace profile (see `headcount`). A title is searched only at the first companies in `sort` order (in `mode: direct`, at most 30: largest first by default, the smallest with `smallest_first`) unless `notes` say `title_matched_first`; `company_fan_out_limit_reached` says more companies matched. Never report it as every company.
    - `excludeWorkspace` (boolean, optional) — `target: companies` only. Default false. When true, companies already in your workspace are left out of `data` instead of coming back as `state: alreadyAdded`, so the page fills with companies you do not have. Use this for "find N new companies". "Already in your workspace" is decided exactly as `state` is. It is not a filter on its own: a filters-only search still needs a company filter. On a filters-only search a page can come back shorter than `limit` with `hasMore: true` when most matches were already yours — keep paging with `nextCursor`.
  - `resolvedWebsites` (object[] | null, required) — What each `filters.companyWebsites` value became, one entry per distinct website, in the order you sent them. Null when no websites were sent, and on a people search with a `query`, which matches each website against the employer of every ranked person instead. A domain can carry more than one company profile — a squatter, a regional page, a subsidiary — so a website resolves only when a profile's name matches the domain and no much larger profile under another name sits beside it; the largest such profile wins. Anything less clear is listed in `unresolvedWebsites` as `uncertain`, with the candidates. Read this before you trust the page: if a `name` here is not the company you meant, the rows under it are not either. To search a different profile, send its id in `filters.companyIds` instead. Lists only the websites that resolved: these are exactly the companies searched.
  - `unresolvedWebsites` (object[] | null, required) — The `filters.companyWebsites` values that did not resolve to a company and were therefore NOT searched, one entry per distinct website, in the order you sent them: `not_found` (no company on that domain) or `uncertain` (several possible companies, listed in `alternatives`). Empty when every website resolved; null when no websites were sent, and on a people search with a `query`. When this is not empty, `data` covers only the companies in `resolvedWebsites`: a short page here is not a sign that the missing companies have nobody matching. For an `uncertain` website, show the alternatives to the user, or search again with `filters.companyIds` set to the right alternative's `id`.
  - `interpreted` (object | null, required) — Present when a `query` was sent; null on a filters-only search.
    - `query` (string, required) — How the sentence was rewritten before searching.
    - `filters` (object, required) — What Trayo read out of the sentence as a constraint, in the request's own `filters` shape. Send it back as `filters` (with or without the `query`) to make the search exact and reproducible. A criterion in your sentence that is not here was NOT enforced; it only steered the rewritten query.
- `400` → ErrorDto: Neither `query` nor `filters` was sent, a field is malformed or outside its closed set, a band is inverted, a people-only field was sent for companies, a cursor was sent with a query, or a filters-only search named no usable company filter, or requested a title without an indexed company lead.

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

`find_needs_indexed_filter` (`retry: fix_input`) — This filters-only search needs an indexed company lead. A people search or company search with a title cannot use country, founded year, technologies or company type alone. A company search without a title can use those filters alone, but checks only a bounded set of the largest companies (`led_by_headcount`). Add at least one of `filters.industries` (see `GET /v1/find/industries`), `filters.hq.cities`, `filters.hq.states`, a `filters.headcount` band, or send a `query` and let the sentence select the set. With no company criteria in the request, do not ask the user: read `buyerProfile` from `GET /v1/workspace`, map its industries to exact `GET /v1/find/industries` values for `filters.industries`, and send its `sizeBand` (max capped at 50,000) as `filters.headcount` when `buyerProfileSource` is `saved`. With a partial profile, no mapped industry or no profile, send `{"min": 1, "max": 10000}`, never `{"min": 1}` alone.

`malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.
- `401` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: Trayo could not identify a company for any `filters.companyWebsites` value, so there was nothing to search. The message names the websites; for an ambiguous one it also names the candidate companies with their ids, to send back as `filters.companyIds`. When only SOME websites fail to resolve, the search runs over the rest and the ones left out are listed in `unresolvedWebsites` instead.

`company_not_found` (`retry: do_not_retry`) — We could not identify this company — not from the account's own record, nor from a LinkedIn page, website (including its redirect), or name — so there are no people to search. Retrying will not help. Send the company's own website if you did not; for any company, `POST /v1/find` on the contacts lane searches the web instead. From `POST /v1/find` with `filters.companyWebsites`, this means NO website resolved and nothing was searched; the message names every website. For one that is ambiguous (several possible companies on the domain) it also names the candidates and their ids: send the right id as `filters.companyIds`. When only some websites fail, the search runs over the rest and lists the others in `unresolvedWebsites`.
- `409` → ErrorDto: TWO DIFFERENT FAILURES AT ONE STATUS, and `retry` is what tells them apart — they demand OPPOSITE responses. Never branch on the 409 alone.

`idempotency_key_reused` (`retry: fix_input`) — This `Idempotency-Key` was already used with a different request body. Repeating this request unchanged will keep failing; use a new key.

`idempotency_in_progress` (`retry: retry_later`) — This operation is still in progress. Retry the same request with the same Idempotency-Key.
- `429` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: `find_response_failed` (`retry: do_not_retry`) — The search completed and was charged, but its response could not be produced. The same Idempotency-Key preserves this outcome; a new key starts another paid search. Contact support.
- `503` → ErrorDto: Read `code` and `retry` to choose the next action. `find_unavailable` (`retry_later`): the search service failed. Wait, then send the same request again. `find_timeout` (`fix_input`): the search exceeded its time limit. Narrow the query or filters before trying again. `find_not_configured` (`do_not_retry`): this search is unavailable. No charge applies. `data_search_busy`: direct search is at capacity or briefly unavailable; wait `details.retryAfterSeconds` before retrying. `data_search_timeout` (`fix_input`): a search by filters, with or without `mode`, reached its time limit. Change what `message` names before retrying; the same search runs out of time again. It is not an empty result.

`find_unavailable` (`retry: retry_later`) — The search service failed. Wait, then send the same request again. A successful search with incomplete data returns 200 with `degraded: true`.

`find_timeout` (`retry: fix_input`) — The search exceeded its time limit. Narrow the query or filters before trying again. A search that already started can still finish. Do not repeat the identical request.

`find_not_configured` (`retry: do_not_retry`) — This type of search is unavailable. Nothing was charged. Retrying or rewording the request will not help. Contact support. The `message` lists any available alternatives, such as an exact company-name lookup or a filters-only search.

`data_search_busy` (`retry: retry_later`) — Hosted search capacity (direct Find, post keyword search, job-change search, job posting search) is in use: your workspace already has as many of these searches running as it may, or the service is at capacity. Nothing ran. Wait for details.retryAfterSeconds, then retry, and send fewer at once.

`data_search_timeout` (`retry: fix_input`) — A hosted search (Find by filters, with or without `mode: direct`; post keyword search; job-change search; job posting search; post search over dates older than 90 days) ran out of time. Change what `message` names and retry; resending the same search runs out of time again, and a timeout is not an empty result.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/find" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":"people","filters":{"industries":["software development"],"hq":{"regions":["europe"]},"headcount":{"min":10,"max":200},"foundedYear":{"min":2019},"title":{"any":["founder","cto"],"exclude":["assistant"]}},"perCompany":2,"limit":25}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/find", headers=HEADERS, json={"target":"people","filters":{"industries":["software development"],"hq":{"regions":["europe"]},"headcount":{"min":10,"max":200},"foundedYear":{"min":2019},"title":{"any":["founder","cto"],"exclude":["assistant"]}},"perCompany":2,"limit":25})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/find/industries

`listFindIndustries` — The industry taxonomy `filters.industries` accepts

**Required permissions:** None beyond a valid API key.

Every value `filters.industries` accepts, lowercase, exactly as the Trayo holds it (a few hundred). Read this before your first industry filter: a value that is not on this list matches nothing, and the search will not guess a nearby one. `contains` narrows the list by substring. Free; any key may call it.

**Parameters**

- `contains` (query, string, optional) — Case-insensitive substring to narrow the list, e.g. `software`.

**Responses**

- `200` → IndustriesResponseDto: 
  - `data` (string[], required) — Industry values, lowercase, exactly as `filters.industries` expects them.
- `400` → ErrorDto: `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` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.
- `503` → ErrorDto: `find_not_configured` (`retry: do_not_retry`) — This type of search is unavailable. Nothing was charged. Retrying or rewording the request will not help. Contact support. The `message` lists any available alternatives, such as an exact company-name lookup or a filters-only search.

`find_unavailable` (`retry: retry_later`) — The search service failed. Wait, then send the same request again. A successful search with incomplete data returns 200 with `degraded: true`.

**curl**

```shell
curl "$TRAYO_API_URL/v1/find/industries" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/find/industries", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Workspace

Who your key is (`GET /v1/whoami`: workspace, user, plan, limits) and the settings that steer discovery and stakeholder search: your stakeholder definition and what you sell. One workspace per key.

### GET /v1/workspace

`getWorkspace` — Read workspace onboarding assumptions and settings

**Required permissions:** `accounts:read`.

The workspace your key acts in, including generated solution cards, buyer personas, the ICP buyer profile, and their prose projections. Review generated assumptions with the user before acting on them. One workspace per key; there is nothing to select.

**The stakeholder definition is what makes stakeholder search yours.** Two stages read it. The account-level search that runs when a company is added to the workspace turns it into search keywords and selects people at the company against it; without a definition that search seeds on seniority alone (chiefs, VPs, directors of any function). The per-event stage that a discovery run started with `people: "add"` runs attaches people, with a per-person `reasoning`, to each event; without a definition that stage does not run at all and `expand=people` is `[]` on every event.

**Set it FIRST — before you add accounts, and before you run discovery.** It steers work that starts after it is set and is never applied retroactively, and the account-level search for a company starts the moment that company is added. An account added before the definition existed keeps the people that search found for it then; the way to search it against the new definition is `POST /v1/stakeholders/search`.

**Responses**

- `200` → WorkspaceDto: 
  - `id` (uuid, required) — The workspace your key acts in.
  - `name` (string, required) — The workspace name, as the Trayo app shows it. Read-only here.
  - `revision` (string, required) — Opaque version of the saved onboarding settings. Pass it as expectedRevision when replacing solution or persona lists; re-read on conflict.
  - `stakeholderCriteria` (string | null, required) — Your stakeholder definition, in plain prose: who buys, owns, or influences what you sell, and who does not. It steers BOTH stakeholder stages — the account-level search that runs when a company is added to the workspace, and the per-event people (with `reasoning`) a discovery run started with `people: "add"` attaches. Null until set: the account-level search then seeds on seniority alone and no people are attached to events.
  - `solutions` (string | null, required) — What you sell, in prose. Read by the reasoning that explains why a person or event matters, and by the app when it drafts a stakeholder definition. Null until set.
  - `solutionsBrief` (string | null, required) — A one- or two-sentence version of `solutions`. Null until set.
  - `solutionItems` (object[] | null, required) — Editable solution cards saved during onboarding. Null means this workspace only has prose; an empty array means no cards.
  - `stakeholderPersonas` (object[] | null, required) — Editable buyer and stakeholder personas saved during onboarding. Null means only prose is available.
  - `buyerProfile` (object, required) — The ICP assumptions: buyer thesis, industries, size and geography, exclusions and search angles. Always present. This field was previously null when no profile was saved; it no longer is, so read `buyerProfileSource` instead of checking for null, and check it before treating this as fact.
    - `isB2b` (boolean, required)
    - `notB2bReason` (string, required)
    - `sellerMode` (one of: "horizontal" | "vertical" | "local_smb" | "smb_horizontal" | "enterprise", required)
    - `modeReason` (string, required)
    - `geography` (object, required)
    - `sizeBand` (object, required)
    - `sameIndustryBuyer` (boolean, required)
    - `rivalDefinition` (string, required)
    - `buyerThesis` (string, required)
    - `positiveSignals` (string[], required)
    - `industries` (string[], required)
    - `technologies` (string[], required)
    - `competitorTerms` (string[], required)
    - `categoryKeywords` (string[], required)
    - `existingCustomers` (string[], required)
    - `generationAngles` (string[], required)
  - `buyerProfileSource` (one of: "saved" | "partial" | "inferred" | "unreadable", required) — `saved`: read from onboarding's stored ICP. `partial`: the fields sent to this API are the caller's own; every field never sent is still the placeholder below. `inferred`: nothing was saved, so you got a fixed placeholder — the same wide defaults for every workspace, not read from your site or your name. `unreadable`: an ICP is saved but not in a shape this API can read, so you got that same placeholder and patching is refused; edit it in the Trayo app. Only `saved` fields and fields you supplied yourself are facts about this workspace; confirm the rest before relying on them.
  - `brand` (object | null, required)
    - `domain` (string, required) — The website domain the brand belongs to, e.g. "stripe.com".
    - `name` (string | null, required) — The brand name.
    - `description` (string | null, required) — A one- or two-sentence description of the company.
    - `colors` (string[], required) — Brand colors as lowercase `#rrggbb`, most prominent first.
    - `socials` (object[], required) — Social profiles found for the brand.
    - `logoUrl` (string, required) — Square logo image URL. Public, no key needed.
    - `brandmarkUrl` (string | null, required) — Wide logo lockup image URL (800px wide PNG). Public, no key needed. Null when none.
    - `bannerUrl` (string | null, required) — Social banner image URL (800px wide). Public, no key needed. Null when none.
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/workspace" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/workspace", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### PATCH /v1/workspace

`updateWorkspace` — Correct workspace onboarding assumptions and settings

**Required permissions:** `settings:write`.

Partial update: send only the fields you are changing. `solutionItems` and `stakeholderPersonas` replace complete structured lists and regenerate their prose projections atomically. Pass the latest `revision` as `expectedRevision` for list edits, and re-read and merge on a 409 conflict. Changing solutions clears an omitted `solutionsBrief` rather than leaving an outdated summary. `buyerProfile` patches only supplied ICP assumptions, including nested geography and sizeBand. Legacy prose fields accept `null` to clear, but a direct prose edit discards the matching structured cards. Do not send both forms of the same setting. Discovery uses the saved values on future work.

**Write the definition as prose, the way you would brief a new researcher**: the roles that own or buy what you sell, the roles that influence the decision, and the roles to skip. Example: "Heads of platform, infrastructure, DevOps or SRE, and the VP or Director of Engineering they report to. Not recruiters, not IT, not security, not individual engineers."

**The stakeholder definition is what makes stakeholder search yours.** Two stages read it. The account-level search that runs when a company is added to the workspace turns it into search keywords and selects people at the company against it; without a definition that search seeds on seniority alone (chiefs, VPs, directors of any function). The per-event stage that a discovery run started with `people: "add"` runs attaches people, with a per-person `reasoning`, to each event; without a definition that stage does not run at all and `expand=people` is `[]` on every event.

**Set it FIRST — before you add accounts, and before you run discovery.** It steers work that starts after it is set and is never applied retroactively, and the account-level search for a company starts the moment that company is added. An account added before the definition existed keeps the people that search found for it then; the way to search it against the new definition is `POST /v1/stakeholders/search`.

**Request body** (`application/json`)

- `expectedRevision` (string, optional) — The revision returned by trayo_get_workspace or GET /v1/workspace. Required for full-list replacements. A stale revision returns 409; re-read and merge instead of retrying the old list.
- `stakeholderCriteria` (string | null, optional) — Plain prose describing the roles to find and the roles to skip, e.g. "Heads of platform, infrastructure, DevOps or SRE, and the VP or Director of Engineering they report to. Not recruiters, not IT, not individual engineers." Send null to clear it.
- `solutions` (string | null, optional) — What you sell, in prose. Send null to clear it.
- `solutionsBrief` (string | null, optional) — A one- or two-sentence version of `solutions`. Send null to clear it. If solutions or solutionItems change and this is omitted, the old brief is cleared. Do not re-send an old brief that may be inaccurate after the change.
- `solutionItems` (object[], optional) — Replace the complete solution-card list. Requires expectedRevision. The prose `solutions` projection is regenerated atomically, and the old `solutionsBrief` is cleared unless a new one is sent. Send [] to clear the cards; icons are server-owned.
  - `name` (string, required)
  - `description` (string, required)
  - `url` (string | null, optional)
- `stakeholderPersonas` (object[], optional) — Replace the complete buyer-persona list. Requires expectedRevision. `stakeholderCriteria` is regenerated atomically. Send [] to clear the personas; icons are server-owned.
  - `title` (string, required)
  - `seniority` (one of: "C-level" | "VP" | "Director" | "Manager", required)
  - `buyingRole` (one of: "economic_buyer" | "decision_maker" | "champion" | "technical_evaluator" | null, optional)
  - `rationale` (string, required)
- `buyerProfile` (object, optional) — Correct only the supplied ICP assumptions; nested geography and sizeBand are partial too. Requires an existing saved profile. Tenant-domain identity is never writable.
  - `isB2b` (boolean, optional)
  - `notB2bReason` (string, optional)
  - `sellerMode` (one of: "horizontal" | "vertical" | "local_smb" | "smb_horizontal" | "enterprise", optional)
  - `modeReason` (string, optional)
  - `geography` (object, optional)
    - `scope` (one of: "local" | "regional" | "national" | "global", optional)
    - `countries` (string[], optional)
    - `regionLabel` (string, optional)
    - `states` (string[], optional)
    - `cities` (string[], optional)
    - `radiusNote` (string, optional)
  - `sizeBand` (object, optional)
    - `min` (integer, optional)
    - `max` (integer, optional)
  - `sameIndustryBuyer` (boolean, optional)
  - `rivalDefinition` (string, optional)
  - `buyerThesis` (string, optional)
  - `positiveSignals` (string[], optional)
  - `industries` (string[], optional)
  - `technologies` (string[], optional)
  - `competitorTerms` (string[], optional)
  - `categoryKeywords` (string[], optional)
  - `existingCustomers` (string[], optional)
  - `generationAngles` (string[], optional)

**Responses**

- `200` → WorkspaceDto: The settings as they now stand.
  - `id` (uuid, required) — The workspace your key acts in.
  - `name` (string, required) — The workspace name, as the Trayo app shows it. Read-only here.
  - `revision` (string, required) — Opaque version of the saved onboarding settings. Pass it as expectedRevision when replacing solution or persona lists; re-read on conflict.
  - `stakeholderCriteria` (string | null, required) — Your stakeholder definition, in plain prose: who buys, owns, or influences what you sell, and who does not. It steers BOTH stakeholder stages — the account-level search that runs when a company is added to the workspace, and the per-event people (with `reasoning`) a discovery run started with `people: "add"` attaches. Null until set: the account-level search then seeds on seniority alone and no people are attached to events.
  - `solutions` (string | null, required) — What you sell, in prose. Read by the reasoning that explains why a person or event matters, and by the app when it drafts a stakeholder definition. Null until set.
  - `solutionsBrief` (string | null, required) — A one- or two-sentence version of `solutions`. Null until set.
  - `solutionItems` (object[] | null, required) — Editable solution cards saved during onboarding. Null means this workspace only has prose; an empty array means no cards.
  - `stakeholderPersonas` (object[] | null, required) — Editable buyer and stakeholder personas saved during onboarding. Null means only prose is available.
  - `buyerProfile` (object, required) — The ICP assumptions: buyer thesis, industries, size and geography, exclusions and search angles. Always present. This field was previously null when no profile was saved; it no longer is, so read `buyerProfileSource` instead of checking for null, and check it before treating this as fact.
    - `isB2b` (boolean, required)
    - `notB2bReason` (string, required)
    - `sellerMode` (one of: "horizontal" | "vertical" | "local_smb" | "smb_horizontal" | "enterprise", required)
    - `modeReason` (string, required)
    - `geography` (object, required)
    - `sizeBand` (object, required)
    - `sameIndustryBuyer` (boolean, required)
    - `rivalDefinition` (string, required)
    - `buyerThesis` (string, required)
    - `positiveSignals` (string[], required)
    - `industries` (string[], required)
    - `technologies` (string[], required)
    - `competitorTerms` (string[], required)
    - `categoryKeywords` (string[], required)
    - `existingCustomers` (string[], required)
    - `generationAngles` (string[], required)
  - `buyerProfileSource` (one of: "saved" | "partial" | "inferred" | "unreadable", required) — `saved`: read from onboarding's stored ICP. `partial`: the fields sent to this API are the caller's own; every field never sent is still the placeholder below. `inferred`: nothing was saved, so you got a fixed placeholder — the same wide defaults for every workspace, not read from your site or your name. `unreadable`: an ICP is saved but not in a shape this API can read, so you got that same placeholder and patching is refused; edit it in the Trayo app. Only `saved` fields and fields you supplied yourself are facts about this workspace; confirm the rest before relying on them.
  - `brand` (object | null, required)
    - `domain` (string, required) — The website domain the brand belongs to, e.g. "stripe.com".
    - `name` (string | null, required) — The brand name.
    - `description` (string | null, required) — A one- or two-sentence description of the company.
    - `colors` (string[], required) — Brand colors as lowercase `#rrggbb`, most prominent first.
    - `socials` (object[], required) — Social profiles found for the brand.
    - `logoUrl` (string, required) — Square logo image URL. Public, no key needed.
    - `brandmarkUrl` (string | null, required) — Wide logo lockup image URL (800px wide PNG). Public, no key needed. Null when none.
    - `bannerUrl` (string | null, required) — Social banner image URL (800px wide). Public, no key needed. Null when none.
- `400` → ErrorDto: An empty body, an unknown field, a blank or oversized value, conflicting structured/prose forms, a list edit without expectedRevision, or a buyer-profile edit without a saved profile. `param` names the field.

`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` → ErrorDto: 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` → string: 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` → ErrorDto: The workspace changed since expectedRevision. Re-read and merge the correction.

`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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl -X PATCH "$TRAYO_API_URL/v1/workspace" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expectedRevision":"<expectedRevision>"}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.patch(f"{BASE}/v1/workspace", headers=HEADERS, json={"expectedRevision":"<expectedRevision>"})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/whoami

`whoami` — Who this key is: permissions, workspace and limits

**Required permissions:** None beyond a valid API key.

**Make this your first call.** It confirms the key works and returns the workspace and user, granted and effective key scopes, key expiry, data-access restrictions, workspace request budgets and lookup capabilities. Compare `key.effectiveScopes` with each operation’s `x-trayo-scope-requirements`.

Read-only and free. This call starts no searches and changes no records. A 401 here is the key itself — no other route will accept it either.

`canInitiate` describes workspace lifecycle only; it does not authorize this key. Lookup `available` combines scope, lifecycle, plan and current quota checks for new lookups. `blockedBy` explains missing authority or limits. These are snapshots: workspace request pacing, concurrent work and request-specific checks still apply.

**Responses**

- `200` → WhoamiDto: 
  - `workspace` (object, required)
    - `id` (uuid, required) — The workspace this key acts in.
    - `name` (string, required)
    - `plan` (one of: "FREE" | "PRO" | "GROWTH" | "ENTERPRISE", required)
    - `lifecycle` (one of: "trial_active" | "trial_grace" | "nurturing" | "purged" | "active", required) — `active` and `trial_active` can initiate metered work; the others are read-only. `canInitiate` is this, as a boolean.
  - `actingAs` (object, required) — The user your key acts as. Accounts and lists you create belong to this user and show up under them in the Trayo app.
    - `userId` (uuid, required)
    - `email` (string | null, required)
    - `name` (string | null, required)
  - `key` (object, required)
    - `grantedScopes` (string[], required) — Persisted key grants; not plan eligibility.
    - `effectiveScopes` (string[], required) — Valid grants allowed by the owner’s current workspace permissions.
    - `expiresAt` (date-time | null, required) — Key expiry; null means no scheduled expiry.
    - `label` (string | null, required) — The label the key was issued with, for telling keys apart. `null` when it has none.
  - `restrictions` (object, required)
    - `dataAccess` (object, required)
    - `rateLimits` (object, required) — Effective workspace request budgets per rolling 60 seconds, shared by REST, MCP, all API keys and OAuth users. Daily and total usage quotas are disabled for now; support can adjust workspace throughput. `defaultPerMinute` and `expensivePerMinute` are separate buckets and a call spends one of them; each MCP tool names the bucket it spends in its description and `_meta["trayo/rateLimitBucket"]`.
  - `capabilities` (object, required)
    - `emailLookup` (object, required)
    - `phoneLookup` (object, required)
  - `canInitiate` (boolean, required) — Workspace lifecycle permission to start metered work. This does not grant the key any scope or confirm plan entitlements, available quota or request eligibility.
  - `lookups` (object, required) — Contact-lookup usage and any active allowances. `POST /v1/people/{id}/enrich`, `POST /v1/people/enrich/batch` and `POST /v1/contacts/lookup` all draw on these, one per field LOOKED UP — the draw happens when a lookup starts, whether or not it goes on to find a value. Usage is recorded even when `limit` and `remaining` are `null`, meaning no lookup cap applies. When a numeric allowance applies, budget on the people you send, not the addresses you expect back. A field that never starts a lookup (a value already on the record, one already running, the cooldown after a miss) costs nothing. When a numeric allowance applies, `402 lookup_limit_reached` is what exceeding one looks like.
    - `email` (object, required)
    - `phone` (object, required)
- `401` → ErrorDto: 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` → string: 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` → ErrorDto: 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` → ErrorDto: 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.

**curl**

```shell
curl "$TRAYO_API_URL/v1/whoami" \
  -H "Authorization: Bearer $TRAYO_API_KEY"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.get(f"{BASE}/v1/whoami", headers=HEADERS)
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Research

Quick overviews of a company or a person — the app's own drawer synthesis, as stateless calls: one generated answer over the record Trayo holds, nothing stored, nothing written.

### POST /v1/research/company

`researchCompany` — A quick overview of a company: what it does, why now, and fit

**Required permissions:** `research:trigger`.

Given a company — any company, by its website (or LinkedIn page or name), or an account in your workspace — return a short overview: the firmographic facts, `whyNow` hooks drawn from its recent LinkedIn updates, and `fit` against what your workspace sells.

Returns an overview in the same response, typically within 2–3 seconds. The server stops after 120 seconds by default with `503 research_failed`. Set your client timeout above 120 seconds and retry once on `retry_later`. Each call generates a new answer. It saves nothing to your workspace. The company or person does not need to be in your workspace. This is the same quick overview the Trayo app shows. With usage enforcement on: Each overview returned draws on the workspace's usage quota; a call refused before the overview is generated (`404`, `400`), or one that fails (`503`), does not. Over the quota is `402 quota_exceeded`; over the workspace's rolling 24-hour usage limit is `429 quota_daily_limit`. With it off, usage has no quotas. 

**How it works.** The company is identified exactly as the Trayo app identifies one — LinkedIn page, then website (following its redirect), then name, against the same data; `company.resolvedBy` says which key matched. **Read `company.name` and `company.website` back before you act on the overview** — a guessed handle or a common name can match a different company, and the overview will faithfully describe that one. The record Trayo holds — description, industry, size, tech stack and its recent company updates — informs the overview together with your workspace's `solutions` and stakeholder definition (`GET /v1/workspace`), which is where `fit` comes from. No web search: the "why now" signal is our own recent updates, so `whyNow` is empty when the company has posted nothing substantive.

**Nothing is written.** Adding a company as an account is what puts it on record — background work that persists the company, its people and its events. This is a read: seconds, nothing stored, any company, whether or not you hold it.

**Request body** (`application/json`)

- `company` (object, required)
  - `companyId` (integer | string, optional) — Trayo's own id for the company, as a number or numeric string: the `companyId` a post, post-engager or person-research result carries, or the `id` of a company find result. Not a workspace account UUID. When sent, it is the key: the company is read by this id and nothing else is tried, so an id Trayo does not hold is `company_not_found`. A `name`, `website` or `linkedinUrl` sent beside it is not used.
  - `accountId` (uuid, optional) — An account in your workspace, on its own. Its existing match is used when it has one; otherwise the account is resolved from its website, LinkedIn handle and name exactly as the app would, so an account we have not yet matched still works when the company is in our data.
  - `website` (string, optional) — The company's own website, as a URL or a domain (`https://zapier.com`, `zapier.com`). The strongest key after the LinkedIn page: it is matched against our data, then, on a miss, followed through a cross-domain redirect, so a legacy or vanity domain that forwards to the current site still resolves. Sent on its own as the bare site (no path or subdomain), a data or social platform's domain names the platform itself (`zoominfo.com` is ZoomInfo); a page on such a site (`linkedin.com/company/acme`) is not a company website. When our data files the company under a different website, a website sent on its own is also tried as the LinkedIn page it spells (`gong.io` as `gong-io`), accepted only for an established company whose name matches the domain. An alias that does not forward to the company's current site is a miss: send the current website, the LinkedIn page or the name. Does not have to be in your workspace.
  - `linkedinUrl` (string, optional) — The company's LinkedIn page — the `linkedinUrl` a find result or a person row carries, copied through (`https://www.linkedin.com/company/zapier`); the bare handle (`zapier`) is accepted too. Tried first when present, and the most precise key there is. Showcase pages are not company pages: send the company's own. Copy the handle from a Trayo result or the company's own site rather than guessing it from the name — a slug that belongs to a different company resolves to that company.
  - `name` (string, optional) — The company name. Tried last and the weakest key, since a common name can match the wrong company — send the website when you have it. A name-only lookup finds only companies Trayo holds under that name; an employer that a Trayo person row names with `companyId: null` has no company record, and its name will usually miss.

**Responses**

- `200` → CompanyResearchResponseDto: The overview, with the company as resolved.
  - `company` (object, required)
    - `companyId` (string, required) — Trayo's id for the company. Send it back as `companyId` to name this exact company again.
    - `name` (string, required) — The company as Trayo records it.
    - `website` (string | null, required) — The company's website domain as Trayo files it.
    - `linkedinUrl` (string | null, required) — The company's LinkedIn page, when Trayo has it.
    - `accountId` (uuid | null, required) — The workspace account for this company: the one you sent, or the account that already holds its website or LinkedIn page. Null when the company is not in your workspace.
    - `resolvedBy` (one of: "account_match" | "company_id" | "linkedin" | "website" | "redirect" | "name", required) — Which key identified the company — the same resolver the Trayo app uses, in its order. `account_match`: the account's existing match. `linkedin`: the LinkedIn page, as sent or as spelled by a website sent on its own. `website`: the website domain. `redirect`: the website, after following a cross-domain redirect to the company's current site. `name`: the name, the weakest key. A match is exact on the key you sent, which is only the company you meant if the key was. A slug or name can belong to a different company than the one you mean, and the answer will then describe that company. Read `name` and `website` back before acting, and prefer `website` as the key — a domain resolves to whoever owns it.
    - `nameMatch` (one of: "exact" | "approximate" | null, required) — Set only when `resolvedBy` is `name`, null otherwise. `exact`: the company's name equals the name looked up once legal suffixes (Inc, GmbH, Ltd) are dropped. `approximate`: the closest name Trayo holds, which can be a different company ("CR Management Consulting" answered with "m3 management consulting"). Check an `approximate` match against what you know of the company before using it, and leave it out when it does not fit.
  - `research` (object, required) — The overview as structured fields. `reportMarkdown` is the same content rendered for display. Neither field carries source links.
    - `displayName` (string, required)
    - `oneLiner` (string, required) — One sentence on what the company does.
    - `industry` (string | null, required)
    - `hqLocation` (string | null, required)
    - `employeeCount` (string | null, required) — A count or a band, as Trayo states it.
    - `foundingYear` (string | null, required)
    - `whyNow` (object[], required) — Timely hooks drawn from the company's recent LinkedIn updates — launches, funding, M&A, leadership changes, hiring. Empty when Trayo holds no substantive recent update, which is an honest answer, not an error.
    - `fit` (object[], required) — Why this company is a target for what your workspace sells, inferred from its industry, size and tech stack against your `solutions`.
  - `reportMarkdown` (string, required) — The same overview rendered as Markdown, ready to show — headings and bullet points. `research` is the structured form of it. Carries no source links; see the note on `research`.
  - `generatedAt` (date-time, required) — When this overview was generated — always this call; nothing is stored.
- `400` → ErrorDto: `company` with neither an `accountId` nor any of `website` / `linkedinUrl` / `name`, or with `accountId` alongside them; a `linkedinUrl` that is a LinkedIn URL but not a company page; or an unknown field. `param` names it.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: `account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.

`company_not_found` (`retry: do_not_retry`) — We could not identify this company — not from the account's own record, nor from a LinkedIn page, website (including its redirect), or name — so there are no people to search. Retrying will not help. Send the company's own website if you did not; for any company, `POST /v1/find` on the contacts lane searches the web instead. From `POST /v1/find` with `filters.companyWebsites`, this means NO website resolved and nothing was searched; the message names every website. For one that is ambiguous (several possible companies on the domain) it also names the candidates and their ids: send the right id as `filters.companyIds`. When only some websites fail, the search runs over the rest and lists the others in `unresolvedWebsites`.
- `429` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: 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.
- `503` → ErrorDto: `research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support.

`research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/research/company" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"company":{"companyId":1}}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/research/company", headers=HEADERS, json={"company":{"companyId":1}})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/research/person

`researchPerson` — A quick overview of a person: career, likely challenges, and angles

**Required permissions:** `research:trigger`.

Given a person — any person, by their LinkedIn profile, or a person in your workspace — return a short stakeholder overview: their career progression, `professionalInsights` tied to what your workspace sells, and the `challenges` they are likely up against.

Returns an overview in the same response, typically within 2–3 seconds. The server stops after 120 seconds by default with `503 research_failed`. Set your client timeout above 120 seconds and retry once on `retry_later`. Each call generates a new answer. It saves nothing to your workspace. The company or person does not need to be in your workspace. This is the same quick overview the Trayo app shows. With usage enforcement on: Each overview returned draws on the workspace's usage quota; a call refused before the overview is generated (`404`, `400`), or one that fails (`503`), does not. Over the quota is `402 quota_exceeded`; over the workspace's rolling 24-hour usage limit is `429 quota_daily_limit`. With it off, usage has no quotas. 

**How it works.** A workspace person's existing match is read; without one, their LinkedIn username is looked up, which is also what a profile sent as `linkedinUrl` does. `person.resolvedBy` says which. The profile Trayo holds — headline and work history — informs the overview together with your workspace's `solutions` and stakeholder definition (`GET /v1/workspace`).

**`grounded`.** Ungrounded, the default: no web search, and the overview is built from the profile Trayo holds alone, so `personalInsights`, `companyIntel`, `location` and `education` are empty and `challenges` are inferences. Grounded, Trayo searches the web for timely company intel and personal facts — slower (5–15 seconds rather than 2–3), and it occasionally fails to answer (`503 research_failed`; retry once). Both modes draw on the quota the same way. A web fact Trayo could not source is withheld rather than stated, so an empty `location` means "could not be sourced", not "unknown". **Source links are not published** — the requirement to have one gates what is said, and the link itself is not returned, in `research` or in `reportMarkdown`. Treat a grounded overview as a lead to check, not as sourced evidence.

**Compared with `POST /v1/stakeholders/search`.** That finds WHO at a company fits a definition; this says what to know about ONE person you already have in hand — a stakeholder-search result's `linkedinUrl` goes straight into `person.linkedinUrl` here.

**Request body** (`application/json`)

- `person` (object, required)
  - `personId` (uuid, optional) — A person in your workspace, on its own. Their existing match is read when they have one; otherwise their LinkedIn username is looked up, so a person you have only just imported still works when the profile is in our data.
  - `linkedinUrl` (string, optional) — The person's LinkedIn profile — the `linkedinUrl` a find result or a stakeholder-search result carries, copied through (`https://www.linkedin.com/in/jane-doe`); the bare handle (`jane-doe`) is accepted too. Does not have to be in your workspace.
- `grounded` (boolean, optional) — Off by default: no web search, and the overview is built from the profile Trayo holds alone — fast and reliable. `true` adds web search to recover timely company intel and personal facts: slower (5–15 s rather than 2–3 s), and Trayo occasionally fails to answer in this mode (`503 research_failed`, retry once). Both modes draw on the workspace's usage quota the same way. What it finds must be sourced to be said, but the sources are not published — treat the result as a lead to check rather than as evidence.

**Responses**

- `200` → PersonResearchResponseDto: The overview, with the person as resolved.
  - `person` (object, required)
    - `fullName` (string, required) — The person as Trayo records them.
    - `linkedinUrl` (string | null, required) — Their LinkedIn profile, when Trayo has it.
    - `photoUrl` (string | null, 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.
    - `location` (object | null, 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. From the profile Trayo holds, in every mode. Not the same field as `research.location`, which is a web fact stated only when `grounded` is on and Trayo could cite it.
    - `companyDomain` (string | null, required) — The website domain of the employer named in research.company, for displaying its logo. Null when Trayo has no matching employer domain.
    - `company` (object | null, required) — The employer of the person's current role on the profile Trayo holds. Usually the company `research.company` names, which is free text. Null when the profile shows no current role.
    - `personId` (uuid | null, required) — The person this call was scoped to, when you sent one.
    - `accountId` (uuid | null, required) — That person's account in your workspace, when they have one.
    - `resolvedBy` (one of: "stored_match" | "linkedin", required) — `stored_match`: the workspace person's existing match. `linkedin`: the LinkedIn profile, looked up by its handle.
  - `grounded` (boolean, required) — Whether web-search grounding was used for this overview.
  - `research` (object, required) — The overview as structured fields. `reportMarkdown` is the same content rendered for display. Neither field carries source links: a grounded fact Trayo could not source is withheld rather than stated, and the link behind one it could source is not returned.
    - `fullName` (string, required)
    - `title` (string | null, required)
    - `company` (string | null, required)
    - `careerProgression` (string[], required) — Notable roles, oldest first, each as `Role at Company (start – end)`.
    - `professionalInsights` (object[], required) — Their role and trajectory, tied to how what your workspace sells could be relevant.
    - `challenges` (object[], required) — What they are likely up against. Inferred from role, seniority and industry when `grounded` is off; evidenced and cited when it is on.
    - `personalInsights` (object[], required) — Only ever filled when `grounded` is on; empty otherwise.
    - `companyIntel` (object[], required) — Timely, cited facts about their company a seller should know now. Only ever filled when `grounded` is on; empty otherwise.
    - `location` (string | null, required) — Present only when `grounded` is on AND Trayo could cite a source for it. An uncited location is withheld rather than guessed, so null means "could not be sourced", never "lives nowhere". The source itself is not published.
    - `education` (string | null, required) — Same rule as `location`: sourced or withheld.
  - `reportMarkdown` (string, required) — The same overview rendered as Markdown, ready to show — headings and bullet points. `research` is the structured form of it. Carries no source links; see the note on `research`.
  - `generatedAt` (date-time, required) — When this overview was generated — always this call; nothing is stored.
- `400` → ErrorDto: `person` with neither or both of `personId` / `linkedinUrl`; a `linkedinUrl` that is a LinkedIn URL but not a profile; `grounded` not a boolean; or an unknown field. `param` names it.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: `person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.

`person_profile_not_found` (`retry: do_not_retry`) — We hold no public profile for this person — the LinkedIn profile is not in our data, or the person in your workspace carries neither a match to it nor a LinkedIn username. Retrying will not help. Send the person's LinkedIn profile URL if you sent a `personId`. Distinct from `person_not_found`, which means there is no such person in your workspace at all.
- `429` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: 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.
- `503` → ErrorDto: `research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support.

`research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/research/person" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"person":{"personId":"11111111-1111-4111-8111-111111111111"}}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/research/person", headers=HEADERS, json={"person":{"personId":"11111111-1111-4111-8111-111111111111"}})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### POST /v1/research/person/batch

`researchPersonBatch` — A quick overview for up to 10 people in one call

**Required permissions:** `research:trigger`.

Returns every overview in the same response. One person typically takes 2–3 seconds, and people are synthesized several at a time, so a full 10-person call takes a small multiple of that rather than 10 times it. The server stops after 120 seconds by default with `503 research_failed`. Set your client timeout above 120 seconds and retry once on `retry_later`. Each call generates a new answer. It saves nothing to your workspace. The company or person does not need to be in your workspace. This is the same quick overview the Trayo app shows. With usage enforcement on: Each overview returned draws on the workspace's usage quota; a call refused before the overview is generated (`404`, `400`), or one that fails (`503`), does not. Over the quota is `402 quota_exceeded`; over the workspace's rolling 24-hour usage limit is `429 quota_daily_limit`. With it off, usage has no quotas. 

Same as `POST /v1/research/person`, once per person in `people` (1 to 10), reading the workspace seller context once for the whole batch. Always ungrounded — `grounded` is not accepted here. A person that cannot be resolved, or whose overview comes back empty (`person_profile_too_thin`), lands in `errors` by `index` with a `code` and a `retry` class, and never fails the rest of the batch. Only the overviews in `results` draw on the quota — a person in `errors` does not.

**Request body** (`application/json`)

- `people` (object[], required) — 1 to 10 people, each like the single `person` field of POST /v1/research/person. Over the cap is 400 validation_failed, never a clamp. Always ungrounded.
  - `personId` (uuid, optional) — A person in your workspace, on its own. Their existing match is read when they have one; otherwise their LinkedIn username is looked up, so a person you have only just imported still works when the profile is in our data.
  - `linkedinUrl` (string, optional) — The person's LinkedIn profile — the `linkedinUrl` a find result or a stakeholder-search result carries, copied through (`https://www.linkedin.com/in/jane-doe`); the bare handle (`jane-doe`) is accepted too. Does not have to be in your workspace.

**Responses**

- `200` → PersonBatchResearchResponseDto: One outcome per submitted person, in `results` or `errors`.
  - `results` (object[], required) — One overview per resolved person. A mixed batch never fails as a whole.
    - `person` (object, required)
    - `grounded` (boolean, required) — Whether web-search grounding was used for this overview.
    - `research` (object, required) — The overview as structured fields. `reportMarkdown` is the same content rendered for display. Neither field carries source links: a grounded fact Trayo could not source is withheld rather than stated, and the link behind one it could source is not returned.
    - `reportMarkdown` (string, required) — The same overview rendered as Markdown, ready to show — headings and bullet points. `research` is the structured form of it. Carries no source links; see the note on `research`.
    - `generatedAt` (date-time, required) — When this overview was generated — always this call; nothing is stored.
    - `index` (integer, required) — Which entry of the submitted `people` this overview is for, 0-based.
  - `errors` (object[], required) — One entry per person this call could not produce an overview for.
    - `index` (integer, required) — Which entry of the submitted `people` this failure is for, 0-based.
    - `code` (one of: "data_search_busy" | "data_search_timeout" | "validation_failed" | "malformed_cursor" | "invalid_date" | "unknown_account" | "unknown_signal" | "unauthorized" | "insufficient_scope" | "forbidden" | "not_found" | "account_not_found" | "signal_not_found" | "event_not_found" | "discovery_run_not_found" | "person_not_found" | "list_not_found" | "discovery_not_found" | "list_member_not_found" | "method_not_allowed" | "idempotency_in_progress" | "conflict" | "signal_key_conflict" | "idempotency_key_reused" | "list_in_use" | "quota_daily_limit" | "quota_exceeded" | "lookup_limit_reached" | "lookup_start_failed" | "contact_lookup_not_found" | "phone_lookup_failed" | "phone_lookup_expired" | "contact_lookup_failed" | "company_not_found" | "company_resolution_timed_out" | "too_many_names" | "job_change_search_not_configured" | "job_change_search_failed" | "lookalike_search_not_configured" | "lookalike_search_failed" | "competitor_roster_not_configured" | "competitor_roster_failed" | "post_search_not_configured" | "post_search_failed" | "job_search_not_configured" | "job_search_failed" | "post_author_not_covered" | "stakeholder_search_not_configured" | "stakeholder_search_failed" | "stakeholder_search_not_found" | "person_ask_not_configured" | "person_ask_failed" | "person_profile_not_found" | "person_profile_too_thin" | "research_not_configured" | "research_failed" | "find_needs_indexed_filter" | "rate_limited" | "internal_error" | "find_not_configured" | "find_response_failed" | "discovery_run_too_large" | "mcp_result_delivery_failed" | "mcp_result_not_found" | "mcp_result_expired" | "mcp_tool_removed" | "model_unavailable" | "find_unavailable" | "find_timeout" | "app_not_found" | "deployment_not_found" | "app_slug_unavailable" | "app_not_ready" | "app_state_conflict" | "archive_upload_missing" | "archive_too_large" | "archive_rejected" | "public_mode_not_allowed" | "hosted_apps_unavailable" | "public_records_not_configured" | "public_records_unavailable" | "public_records_throttled", required) — Why this person failed, in the error envelope's `code` vocabulary, e.g. `person_not_found`. Usually the code a single call would return; `person_profile_too_thin` is the batch's own, for an overview that came back empty.
    - `retry` (one of: "fix_input" | "retry_later" | "do_not_retry", required) — What to do about the failure, and the only field you need in order to decide whether to send the request again. `fix_input` — the request is wrong; change it and retry. `retry_later` — the request is right, the system is not ready; retry it unchanged, with backoff and a FINITE number of attempts. On a 500 (`internal_error`) this especially does not mean retry indefinitely: the failure is not known to be transient, only that nothing about your request needs changing, so give up after a few attempts rather than looping. `do_not_retry` — retrying will never help. Fixed per `code`; see the table above. Always present, on every error, including any code added after this document — so a client that does not recognise a `code` can still act correctly by reading this field alone.
    - `message` (string, required)
    - `param` (string, optional) — Names the offending field, e.g. `people[2].personId`.
- `400` → ErrorDto: `people` empty, over 10, containing an item with neither or both of `personId` / `linkedinUrl`, or a top-level `grounded` field.

`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` → ErrorDto: 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` → ErrorDto: `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` → string: 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` → ErrorDto: 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.

`quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `500` → ErrorDto: 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.
- `503` → ErrorDto: `research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support.

`research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly.

**curl**

```shell
curl -X POST "$TRAYO_API_URL/v1/research/person/batch" \
  -H "Authorization: Bearer $TRAYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"people":[{"personId":"11111111-1111-4111-8111-111111111111"}]}'
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")
HEADERS = {"Authorization": f"Bearer {os.environ['TRAYO_API_KEY']}"}

r = requests.post(f"{BASE}/v1/research/person/batch", headers=HEADERS, json={"people":[{"personId":"11111111-1111-4111-8111-111111111111"}]})
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

## Meta

Machine-readable API description: this document, the index, and the recipes. Unauthenticated.

### GET /

`getApiIndex` — Where the documentation is and how to authenticate

Unauthenticated: no `Authorization` header needed.

An unauthenticated JSON index: the URL of the OpenAPI document, the auth scheme, and every route this API serves with its method and summary. Derived from the same generated document the `openapi` URLs return, so it cannot list a route that does not exist.

Served at both `/` and `/v1`.

**Responses**

- `200`: The index.

**curl**

```shell
curl "$TRAYO_API_URL/"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")

r = requests.get(f"{BASE}/")
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/recipes

`listRecipes` — The call sequences that make up a task

Unauthenticated: no `Authorization` header needed.

Unauthenticated. Each recipe is an ordered list of operations from this document — by `operationId`, `method` and `path` — with what each step does in that sequence and, for a poll step, the condition that ends the loop (`until`). `when` says which recipe fits a task; `spends` says what it costs before you start.

The same list is carried in the OpenAPI document as `x-recipes` and rendered in `/llms.txt`. A recipe can only name an operation this document describes: the list is resolved against the document, not maintained beside it.

**Responses**

- `200`: The recipes.
- `429` → ErrorDto: Unauthenticated, so this route is limited per source network rather than per workspace and carries `Retry-After` alone — there is no workspace bucket to report, and no `RateLimit-*` headers. Fetch this document once and reuse it rather than re-reading it per call.

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

**curl**

```shell
curl "$TRAYO_API_URL/v1/recipes"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")

r = requests.get(f"{BASE}/v1/recipes")
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /v1/openapi.yaml

`getOpenApiDocument` — This document

Unauthenticated: no `Authorization` header needed.

Unauthenticated, so that a caller can fetch this document before it has a key configured. Generated from the running server's own routes, so it cannot drift from the API it describes.

The same document is served at `GET /openapi.json` and `GET /.well-known/openapi`. The extension chooses the default serialization — YAML here, JSON there — and an explicit `Accept` of `application/json` or `application/yaml` overrides it either way.

**Responses**

- `200` → object: This OpenAPI document.
- `429` → ErrorDto: Unauthenticated, so this route is limited per source network rather than per workspace and carries `Retry-After` alone — there is no workspace bucket to report, and no `RateLimit-*` headers. Fetch this document once and reuse it rather than re-reading it per call.

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

**curl**

```shell
curl "$TRAYO_API_URL/v1/openapi.yaml"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")

r = requests.get(f"{BASE}/v1/openapi.yaml")
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /openapi.json

`getOpenApiDocumentJson` — This document, as JSON

Unauthenticated: no `Authorization` header needed.

The same document as `GET /v1/openapi.yaml`, serialized as JSON. Send `Accept: application/yaml` to get YAML from this URL instead.

**Responses**

- `200` → object: This OpenAPI document.

**curl**

```shell
curl "$TRAYO_API_URL/openapi.json"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")

r = requests.get(f"{BASE}/openapi.json")
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```

### GET /.well-known/openapi

`getWellKnownOpenApiDocument` — This document, at its well-known URI

Unauthenticated: no `Authorization` header needed.

Identical to `GET /openapi.json`.

**Responses**

- `200` → object: This OpenAPI document.

**curl**

```shell
curl "$TRAYO_API_URL/.well-known/openapi"
```

**Python (requests)**

```python
import os
import requests

BASE = os.environ.get("TRAYO_API_URL", "https://api.trayo.ai")

r = requests.get(f"{BASE}/.well-known/openapi")
body = r.json()
if not r.ok:
    err = body["error"]
    raise SystemExit(f"{r.status_code} {err['code']} ({err['retry']}): {err['message']}")
print(body)
```
