{"openapi":"3.1.0","paths":{"/v1/accounts/lookalikes":{"post":{"description":"**Required permissions:** `accounts:read`.\n\nSend 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.\n\nReturns 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.\n\nSynchronous, 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.","operationId":"findLookalikeCompanies","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookalikeSearchBodyDto"},"examples":{"companies":{"value":{"companies":[{"linkedinUrl":"https://www.linkedin.com/company/stripe","website":"https://stripe.com","name":"Stripe"}],"limit":100}},"companyIds":{"value":{"companies":[{"companyId":6871792},{"companyId":"851530"}],"limit":100}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookalikeSearchResponseDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`lookalike_search_not_configured` (`retry: do_not_retry`) — Lookalike search is unavailable. Retrying will not help. Contact support.\n\n`lookalike_search_failed` (`retry: retry_later`) — Lookalike search failed or exceeded its time budget. Nothing was saved; retry the same request shortly.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find companies similar to a list of seed companies","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/accounts/lookalikes\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"companies\":[{\"companyId\":1}]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/accounts/lookalikes\", headers=HEADERS, json={\"companies\":[{\"companyId\":1}]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/companies/competitors":{"post":{"description":"**Required permissions:** `accounts:read`.\n\nSend 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.\n\nThe 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.","operationId":"generateCompetitorRoster","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompetitorRosterBodyDto"},"examples":{"self":{"value":{"company":{"self":true}}},"withPins":{"value":{"company":{"website":"qodo.ai"},"pins":[{"website":"coderabbit.ai"}],"cap":10}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompetitorRosterResponseDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`competitor_roster_not_configured` (`retry: do_not_retry`) — Competitor rosters are unavailable in this environment. Retrying will not help. Contact support.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Rank the competitors of one company","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/companies/competitors\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"company\":{\"self\":true}}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/companies/competitors\", headers=HEADERS, json={\"company\":{\"self\":True}})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/accounts":{"get":{"description":"**Required permissions:** `accounts:read`.\n\nA page of accounts, most recently created first. PAGINATES: follow `nextCursor` until `hasMore` is false.","operationId":"listAccounts","parameters":[{"description":"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`.","required":false,"name":"q","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"updatedSince","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"cursor","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"limit","in":"query","schema":{"minimum":1,"maximum":9007199254740991,"type":"integer"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountListDto"}}}},"400":{"description":"`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List accounts","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/accounts\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/accounts\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"post":{"description":"**Required permissions:** `accounts:write`.\n\nAn 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.\n\nYou 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.\n\nPublic REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **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`.\n\nTo 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.\n\n**A `POST /v1/find` company row is NOT postable verbatim.** Its `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `fundingStage`, `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}'`.","operationId":"createAccount","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAccountDto"}}}},"responses":{"201":{"description":"Account created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Create an account","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/accounts\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"<name>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/accounts\", headers=HEADERS, json={\"name\":\"<name>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/accounts/batch":{"post":{"description":"**Required permissions:** `accounts:write`.\n\nCreate 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.\n\n**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.\n\n**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.\n\n**Project a find result before posting it — it is not accepted verbatim.** A find company row carries `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `fundingStage`, `foundedYear`, `companyType`, `description`, `why`, `known`, `state` alongside `name` and `url`, this route accepts none of them, and an unprojected batch therefore lands EVERY row in `skipped` (the reason names the keys). The import that works:\n\n```\ncurl -s .../v1/find -d '{\"target\":\"companies\",\"query\":\"...\"}' | jq '{accounts: [.data[] | select(.state == \"selectable\" and .url) | {name, url}]}' \\\n  | curl -s .../v1/accounts/batch -H 'content-type: application/json' -d @-\n```\n\nThe `select(.state == \"selectable\")` half matters as much as the projection: an `alreadyAdded` row would be skipped as a duplicate anyway.\n\n**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`.\n\nPublic REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **Dedup is best-effort, not a guarantee.** A row is a duplicate when its `url`'s hostname matches, lowercased and with a leading `www.` stripped (`https://acme.com` and `https://www.acme.com/careers` collide, and so does a stored value kept without a scheme, `acme.com`). Failing that, a row is a duplicate when its `linkedinHandle` matches — so a company you already have under a different domain (a rebrand, a regional TLD, an account whose `url` is empty) is still recognised. That comparison folds together every shape the handle is stored in: case, percent-encoding (`airbus%2Dhelicopters`), and a full company URL rather than the bare slug. Last, a row is a duplicate by `name`, case-insensitively, and how far that key reaches depends on what else the row carries. A row with NEITHER a `url` NOR a `linkedinHandle` has only its name, so it is matched against every account in your workspace, those with a website as well as those without — a name-only import is not created twice, and it attaches to the company you already have. A row that DOES carry a `url` or a handle and missed on it is matched by name only against an account that has NEITHER a usable website NOR a `linkedinHandle` — the only case where a name is the sole thing the two records share. Two accounts that each have a real website are never matched on name, so two different companies with the same name both land. The `reason` on the skip names WHICH of the three keys collided.\n\n**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`.\n\nThis 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.","operationId":"batchCreateAccounts","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchCreateAccountsDto"}}}},"responses":{"200":{"description":"The request was processed. Check `skipped` — a `200` does not mean every row was created; it means the call itself succeeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchCreateAccountsResultDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"The workspace quota cannot cover the accounts this batch would create. Nothing was created.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Batch create accounts","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/accounts/batch\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"accounts\":[\"<accounts>\"]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/accounts/batch\", headers=HEADERS, json={\"accounts\":[\"<accounts>\"]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/accounts/{id}":{"get":{"operationId":"getAccount","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"No such account, OR it belongs to another workspace — the two are indistinguishable on purpose.\n\n`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Get an account","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:read"]}},"description":"**Required permissions:** `accounts:read`.","x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl \"$TRAYO_API_URL/v1/accounts/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.get(f\"{BASE}/v1/accounts/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"patch":{"operationId":"updateAccount","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAccountDto"}}}},"responses":{"200":{"description":"The account after the update.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDto"}}}},"400":{"description":"Malformed body.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Update an account","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:write"]}},"description":"**Required permissions:** `accounts:write`.","x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X PATCH \"$TRAYO_API_URL/v1/accounts/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"<name>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.patch(f\"{BASE}/v1/accounts/{id}\", headers=HEADERS, json={\"name\":\"<name>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"delete":{"description":"**Required permissions:** `accounts:delete`.\n\nSoft 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`.","operationId":"deleteAccount","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The account as it existed immediately before deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Delete an account","tags":["Accounts"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:delete"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X DELETE \"$TRAYO_API_URL/v1/accounts/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.delete(f\"{BASE}/v1/accounts/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/signals":{"get":{"description":"**Required permissions:** `events:read`.\n\nReturns the complete current set of your workspace's account-axis signals in a SINGLE response — every signal you have authored.\n\n**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.","operationId":"listSignals","parameters":[],"responses":{"200":{"description":"All current signals.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalListDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List signals","tags":["Signals"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/signals\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/signals\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"post":{"description":"**Required permissions:** `settings:write`.\n\nCreate 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.","operationId":"createSignal","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSignalDto"}}}},"responses":{"201":{"description":"Signal created, and usable immediately: name its key in `POST /v1/discoveries` as soon as this answers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"409":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Create a signal","tags":["Signals"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["settings:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/signals\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"signalKey\":\"<signalKey>\",\"type\":\"news\",\"detects\":\"<detects>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/signals\", headers=HEADERS, json={\"signalKey\":\"<signalKey>\",\"type\":\"news\",\"detects\":\"<detects>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/signals/{signalKey}":{"get":{"description":"**Required permissions:** `events:read`.\n\nOne 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.","operationId":"getSignal","parameters":[{"name":"signalKey","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"No signal with this key, or it is a reserved product-managed or person-entity signal and not addressable here.\n\n`signal_not_found` (`retry: do_not_retry`) — No such signal key, or it is product-managed/person-entity and not addressable here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Get a signal","tags":["Signals"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {signalKey} with the real value.\ncurl \"$TRAYO_API_URL/v1/signals/{signalKey}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nsignal_key = \"...\"  # the {signalKey} in the path\nr = requests.get(f\"{BASE}/v1/signals/{signal_key}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"patch":{"description":"**Required permissions:** `settings:write`.\n\nUpdates create a new signal version. It takes effect on the next run that names the signal.","operationId":"updateSignal","parameters":[{"name":"signalKey","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSignalDto"}}}},"responses":{"200":{"description":"The signal after the update.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalDto"}}}},"400":{"description":"Malformed body, or the body tried to set `signalKey`/`type`, which this route does not accept.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`signal_not_found` (`retry: do_not_retry`) — No such signal key, or it is product-managed/person-entity and not addressable here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Update a signal","tags":["Signals"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["settings:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {signalKey} with the real value.\ncurl -X PATCH \"$TRAYO_API_URL/v1/signals/{signalKey}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"detects\":\"<detects>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nsignal_key = \"...\"  # the {signalKey} in the path\nr = requests.patch(f\"{BASE}/v1/signals/{signal_key}\", headers=HEADERS, json={\"detects\":\"<detects>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"delete":{"description":"**Required permissions:** `settings:write`.\n\nThe 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.","operationId":"deleteSignal","parameters":[{"name":"signalKey","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Confirmation, not the deleted signal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalDeleteResultDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`signal_not_found` (`retry: do_not_retry`) — No such signal key, or it is product-managed/person-entity and not addressable here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Delete a signal","tags":["Signals"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["settings:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {signalKey} with the real value.\ncurl -X DELETE \"$TRAYO_API_URL/v1/signals/{signalKey}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nsignal_key = \"...\"  # the {signalKey} in the path\nr = requests.delete(f\"{BASE}/v1/signals/{signal_key}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/discoveries":{"post":{"description":"**Required permissions:** `events:write`.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\nYou can use new accounts and signal keys immediately. Poll the same run until `settledAt` is set.\n\nPublic REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **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.\n\nRead `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.\n\n**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.","operationId":"startDiscovery","parameters":[{"name":"Idempotency-Key","in":"header","description":"Free-form string. Supported on THIS route only — no other `/v1` route reads it.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartDiscoveryDto"}}}},"responses":{"202":{"description":"Run accepted (or, for a replayed Idempotency-Key, the original run's current state).","headers":{"Retry-After":{"description":"Sent only while the run has not settled: seconds to wait before reading it again, the same value as `progress.retryAfterSeconds`. Absent once `settledAt` is set.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoveryRunDto"}}}},"400":{"description":"`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.\n\n`unknown_signal` (`retry: fix_input`) — One or more `signalKeys` do not exist in your workspace. `message` lists them.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"409":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Start a discovery run","tags":["Discoveries"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/discoveries\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"accountIds\":[\"11111111-1111-4111-8111-111111111111\"],\"lookbackDays\":1}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/discoveries\", headers=HEADERS, json={\"accountIds\":[\"11111111-1111-4111-8111-111111111111\"],\"lookbackDays\":1})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"get":{"description":"**Required permissions:** `events:read`.\n\nEvery run in your workspace, most recently started first. No filter by status or account — pagination only. PAGINATES: follow `nextCursor` until `hasMore` is false.","operationId":"listDiscoveries","parameters":[{"description":"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.","required":false,"name":"cursor","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"limit","in":"query","schema":{"minimum":1,"maximum":9007199254740991,"type":"integer"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoveryRunListDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List discovery runs","tags":["Discoveries"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/discoveries\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/discoveries\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/discoveries/{runId}":{"get":{"description":"**Required permissions:** `events:read`.\n\nTHE 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.\n\nTwo 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`.\n\n**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.","operationId":"getDiscovery","parameters":[{"name":"runId","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"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.","headers":{"Retry-After":{"description":"Sent only while the run has not settled: seconds to wait before reading it again, the same value as `progress.retryAfterSeconds`. Absent once `settledAt` is set.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoveryRunDto"}}}},"400":{"description":"`runId` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`discovery_run_not_found` (`retry: do_not_retry`) — No such run in your workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Get a discovery run","tags":["Discoveries"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {runId} with the real value.\ncurl \"$TRAYO_API_URL/v1/discoveries/{runId}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nrun_id = \"...\"  # the {runId} in the path\nr = requests.get(f\"{BASE}/v1/discoveries/{run_id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/events":{"get":{"description":"**Required permissions:** `events:read`.\n\nWhat discovery found. Defaults to `state=live` — dismissed events are excluded unless you ask for them explicitly. PAGINATES: follow `nextCursor` until `hasMore` is false.\n\nAfter 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.","operationId":"listEvents","parameters":[{"description":"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.","required":false,"name":"discoveryRunId","in":"query","schema":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","type":"string"}},{"description":"Restrict to events on one account.","required":false,"name":"accountId","in":"query","schema":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","type":"string"}},{"description":"Restrict to events matched to this signal.","required":false,"name":"signalKey","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"signalKeys","in":"query","schema":{"type":"array","items":{"type":"string"}}},{"description":"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.\n\n**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.","required":false,"name":"since","in":"query","schema":{"type":"string"}},{"description":"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.\n\n**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.","required":false,"name":"discoveredSince","in":"query","schema":{"type":"string"}},{"description":"Defaults to `live` — dismissed events are excluded unless you ask for them.\n\n**`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`.\n\n**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.\n\nSo the two readable states answer different questions: `live` is what stands, `dismissed` is what Trayo withdrew, and what you dismissed is in neither.","required":false,"name":"state","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"expand","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"cursor","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"limit","in":"query","schema":{"minimum":1,"maximum":9007199254740991,"type":"integer"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventListDto"}}}},"400":{"description":"`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`discoveryRunId` does not resolve in your workspace.\n\n`discovery_run_not_found` (`retry: do_not_retry`) — No such run in your workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List events","tags":["Events"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/events\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/events\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/events/{id}":{"get":{"description":"**Required permissions:** `events:read`.\n\nAlways returns `people` and `signals` expanded — unlike the list route, there is no `expand` parameter here because it is not needed.","operationId":"getEvent","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The event, always expanded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"No such event, it belongs to another workspace, or it exists but has not become available yet. All three are indistinguishable.\n\n`event_not_found` (`retry: do_not_retry`) — No such event in your workspace, or it has not finished processing and is not addressable yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Get an event","tags":["Events"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl \"$TRAYO_API_URL/v1/events/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.get(f\"{BASE}/v1/events/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"delete":{"description":"**Required permissions:** `events:write`.\n\nSoft 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`.\n\n**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`.\n\n**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.","operationId":"deleteEvent","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The event as it existed immediately before deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"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.\n\n`event_not_found` (`retry: do_not_retry`) — No such event in your workspace, or it has not finished processing and is not addressable yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Delete (dismiss) an event","tags":["Events"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["events:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X DELETE \"$TRAYO_API_URL/v1/events/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.delete(f\"{BASE}/v1/events/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/people":{"get":{"description":"**Required permissions:** `people:read`.\n\nA page of people, most recently created first. PAGINATES: follow `nextCursor` until `hasMore` is false.\n\n**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.","operationId":"listPeople","parameters":[{"description":"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.","required":false,"name":"accountId","in":"query","schema":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","type":"string"}},{"description":"Case-insensitive substring match against `fullName` only — does not search `title`, `email`, or anything else.","required":false,"name":"q","in":"query","schema":{"type":"string"}},{"description":"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.\n\nIt 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.","required":false,"name":"hasEmail","in":"query","schema":{"type":"string"}},{"description":"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.\n\n**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`.","required":false,"name":"isRepVisible","in":"query","schema":{"type":"string"}},{"description":"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.\n\nA 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.","required":false,"name":"updatedSince","in":"query","schema":{"type":"string"}},{"description":"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.\n\nBoth 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}`.","required":false,"name":"expand","in":"query","schema":{"type":"string"}},{"description":"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`.","required":false,"name":"residenceCity","in":"query","schema":{"minLength":1,"maxLength":100,"type":"string"}},{"description":"Only people whose stated LinkedIn location is in this state, province or region, e.g. `California`. Case-insensitive, whole name.","required":false,"name":"residenceState","in":"query","schema":{"minLength":1,"maxLength":100,"type":"string"}},{"description":"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.","required":false,"name":"residenceCountry","in":"query","schema":{"minLength":2,"maxLength":80,"type":"string"}},{"description":"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.","required":false,"name":"cursor","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"limit","in":"query","schema":{"minimum":1,"maximum":9007199254740991,"type":"integer"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonListDto"}}}},"400":{"description":"`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List people","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/people\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/people\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"post":{"description":"**Required permissions:** `people:write`.\n\n**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`.\n\n**`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.\n\n**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.\n\n**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.\n\n**`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.\n\n**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.\n\nNote `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.\n\n`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.\n\n**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\"`.\n\nA 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>`.","operationId":"createPerson","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonDto"}}}},"responses":{"200":{"description":"Existing person updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDto"}}}},"201":{"description":"Person created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDto"}}}},"400":{"description":"`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"409":{"description":"The LinkedIn identity and email match different existing people. Send one identity, or PATCH the intended record explicitly.\n\n`conflict` (`retry: fix_input`) — The generic 409: the request collides with existing state, and none of the specific codes covers it. `message` says what.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Create a person","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/people\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"fullName\":\"<fullName>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/people\", headers=HEADERS, json={\"fullName\":\"<fullName>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/people/batch":{"post":{"description":"**Required permissions:** `people:write`.\n\nCreate 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`.\n\n**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.\n\n**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.\n\n**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.\n\n**`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.\n\n**`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.\n\n**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.","operationId":"batchCreatePeople","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchCreatePeopleDto"}}}},"responses":{"200":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchCreatePeopleResultDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Batch create people","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/people/batch\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"people\":[\"<people>\"]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/people/batch\", headers=HEADERS, json={\"people\":[\"<people>\"]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/people/{id}":{"get":{"description":"**Required permissions:** `people:read`.\n\nAlways returns `stakeholderReport` and `events` — unlike the list route, there is no `expand` parameter here because it is not needed.\n\nThose 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.","operationId":"getPerson","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The person, always expanded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"No such person, OR it belongs to another workspace — the two are indistinguishable on purpose.\n\n`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Get a person","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl \"$TRAYO_API_URL/v1/people/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.get(f\"{BASE}/v1/people/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"patch":{"description":"**Required permissions:** `people:write`.\n\nOnly the fields present in the body change; everything else is left alone.\n\n**`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.","operationId":"updatePerson","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePersonDto"}}}},"responses":{"200":{"description":"The person after the update.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDto"}}}},"400":{"description":"Malformed body, e.g. `email` not a valid address.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Update a person","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X PATCH \"$TRAYO_API_URL/v1/people/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"fullName\":\"<fullName>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.patch(f\"{BASE}/v1/people/{id}\", headers=HEADERS, json={\"fullName\":\"<fullName>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"delete":{"description":"**Required permissions:** `people:delete`.\n\nSoft 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`.","operationId":"deletePerson","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The person as it existed immediately before deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Delete a person","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:delete"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X DELETE \"$TRAYO_API_URL/v1/people/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.delete(f\"{BASE}/v1/people/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/people/enrich/batch":{"post":{"description":"**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.\n\nStart 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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.","operationId":"batchEnrichPeople","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchEnrichBodyDto"}}}},"responses":{"200":{"description":"The batch was processed. Check `skipped` — a `200` does not mean a lookup started for every person.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchEnrichResultDto"}}}},"400":{"description":"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`.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Look up emails and phones for many people","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"contact-fields","parameter":"body.fields","combine":"union","cases":{"email":["people:enrich_email"],"phone":["people:enrich_phone"]},"defaultFields":["email","phone"],"omitted":["people:enrich_email","people:enrich_phone"],"malformed":[]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/people/enrich/batch\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"personIds\":[\"11111111-1111-4111-8111-111111111111\"]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/people/enrich/batch\", headers=HEADERS, json={\"personIds\":[\"11111111-1111-4111-8111-111111111111\"]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/people/{id}/enrich":{"post":{"description":"**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.\n\nFind this person's email and/or phone the same way the Trayo app's own \"Find email\" does. Omit `fields` for both.\n\n**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.\n\n**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.\n\n**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.\n\n**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.","operationId":"enrichPerson","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrichPersonBodyDto"}}}},"responses":{"202":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrichPersonResultDto"}}}},"400":{"description":"`id` is not a UUID, or `fields` names something other than `email`/`phone`.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Look up an email and/or phone","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"contact-fields","parameter":"body.fields","combine":"union","cases":{"email":["people:enrich_email"],"phone":["people:enrich_phone"]},"defaultFields":["email","phone"],"omitted":["people:enrich_email","people:enrich_phone"],"malformed":[]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X POST \"$TRAYO_API_URL/v1/people/{id}/enrich\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"fields\":[\"email\"]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.post(f\"{BASE}/v1/people/{id}/enrich\", headers=HEADERS, json={\"fields\":[\"email\"]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"get":{"description":"**Required permissions:** `people:read`.\n\nThe 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.","operationId":"getPersonContact","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactStatusDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Read the contact lookup state","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl \"$TRAYO_API_URL/v1/people/{id}/enrich\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.get(f\"{BASE}/v1/people/{id}/enrich\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/people/{id}/ask":{"post":{"description":"**Required permissions:** `people:read`.\n\n**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.\n\nAsk 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. Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **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.\n\n**`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.\n\n**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.\n\n**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.","operationId":"askPerson","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AskPersonBodyDto"}}}},"responses":{"200":{"description":"The answer, what was missing from the record (`gaps`), and a receipt of the records used (`sources`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AskPersonResponseDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`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}`.\n\n`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Ask a question about a person","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X POST \"$TRAYO_API_URL/v1/people/{id}/ask\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"question\":\"<question>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.post(f\"{BASE}/v1/people/{id}/ask\", headers=HEADERS, json={\"question\":\"<question>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/find":{"post":{"description":"**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.\n\nSearch for companies and people you do not already have. It never writes: you get candidates, and you import the ones you want.\n\n**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.\n\n**Say what you want in one of two ways, or both.**\n\n- `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.\n- `filters` — exact attributes: industry, headcount, headquarters, funding stage, 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`, `fundingStages` or a `headcount` band. A funding stage selects a set on its own. Read `GET /v1/find/industries` before your first industry filter, because the values are a closed taxonomy.\n- both — the sentence ranks, the filters decide. Rows Trayo cannot check are dropped.\n\n**`target: people` returns people with their current company.** `\"founders at seed-stage software companies\"` is `{\"target\": \"people\", \"filters\": {\"industries\": [\"software development\"], \"fundingStages\": [\"seed\"], \"title\": {\"any\": [\"founder\"]}}}`. Company filters describe the employer; `title` describes the person. With a `query`, a people search takes `title` only.\n\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n`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.","operationId":"find","parameters":[{"name":"idempotency-key","required":false,"in":"header","description":"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.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FindRequestDto"}}}},"responses":{"200":{"description":"The search ran. An empty `data` array is a successful search that matched nothing — check `truncated`, `degraded` and `interpreted` before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FindResponseDto"}}}},"400":{"description":"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.\n\n`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.\n\n`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`, `filters.fundingStages` or 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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"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.\n\n`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"409":{"description":"TWO DIFFERENT FAILURES AT ONE STATUS, and `retry` is what tells them apart — they demand OPPOSITE responses. Never branch on the 409 alone.\n\n`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.\n\n`idempotency_in_progress` (`retry: retry_later`) — This operation is still in progress. Retry the same request with the same Idempotency-Key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"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.\n\n`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`.\n\n`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.\n\n`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find companies or people not yet in your workspace","tags":["Find"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"find-anchor","parameter":"body.target","cases":{"people":["people:read"],"companies":["accounts:read"]},"otherwise":[]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/find\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"target\":\"people\",\"filters\":{\"industries\":[\"software development\"],\"hq\":{\"regions\":[\"europe\"]},\"headcount\":{\"min\":10,\"max\":200},\"fundingStages\":[\"seed\",\"series_a\"],\"foundedYear\":{\"min\":2019},\"title\":{\"any\":[\"founder\",\"cto\"],\"exclude\":[\"assistant\"]}},\"perCompany\":2,\"limit\":25}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/find\", headers=HEADERS, json={\"target\":\"people\",\"filters\":{\"industries\":[\"software development\"],\"hq\":{\"regions\":[\"europe\"]},\"headcount\":{\"min\":10,\"max\":200},\"fundingStages\":[\"seed\",\"series_a\"],\"foundedYear\":{\"min\":2019},\"title\":{\"any\":[\"founder\",\"cto\"],\"exclude\":[\"assistant\"]}},\"perCompany\":2,\"limit\":25})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/find/industries":{"get":{"description":"**Required permissions:** None beyond a valid API key.\n\nEvery 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.","operationId":"listFindIndustries","parameters":[{"description":"Case-insensitive substring to narrow the list, e.g. `software`.","required":false,"name":"contains","in":"query","schema":{"minLength":1,"maxLength":60,"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndustriesResponseDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`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.\n\n`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"The industry taxonomy `filters.industries` accepts","tags":["Find"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":[]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/find/industries\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/find/industries\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/job-changes/search":{"post":{"description":"**Required permissions:** `people:read`.\n\nStateless 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.","operationId":"searchJobChanges","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobChangeSearchBodyDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobChangeSearchResponseDto"}}}},"400":{"description":"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.\n\n`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.\n\n`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"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`).\n\n`job_change_search_not_configured` (`retry: do_not_retry`) — Job-change search is unavailable. Retrying will not help. Contact support.\n\n`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find recent company moves","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/job-changes/search\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"source\":{\"companyId\":1}}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/job-changes/search\", headers=HEADERS, json={\"source\":{\"companyId\":1}})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/posts/search":{"post":{"description":"**Required permissions:** `people:read`.\n\nSearches 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.\n\nFollow `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.","operationId":"searchPosts","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostSearchBodyDto"},"examples":{"topic":{"value":{"topic":"evaluating a new intent data vendor","windowDays":30,"limit":25}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostSearchResponseDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`post_search_not_configured` (`retry: do_not_retry`) — Post search is unavailable. Retrying will not help. Contact support.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find LinkedIn posts by topic or author","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/posts/search\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"topic\":\"<topic>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/posts/search\", headers=HEADERS, json={\"topic\":\"<topic>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/posts/engagers":{"post":{"description":"**Required permissions:** `people:read`.\n\nSend `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.\n\nSynchronous and read-only; nothing is added to your workspace. To keep people, send them to POST /v1/people/batch.","operationId":"getPostEngagers","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostEngagersBodyDto"},"examples":{"url":{"value":{"post":"https://www.linkedin.com/feed/update/urn:li:activity:7350000000000000000/"}},"postId":{"value":{"post":"7350000000000000000"}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostEngagersResponseDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`post_search_not_configured` (`retry: do_not_retry`) — Post search is unavailable. Retrying will not help. Contact support.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List the author and commenters of a LinkedIn post","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/posts/engagers\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"post\":\"<post>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/posts/engagers\", headers=HEADERS, json={\"post\":\"<post>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/posts/search/keywords":{"post":{"description":"**Required permissions:** `people:read`.\n\nDirect 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.\n\nVerifies 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.","operationId":"searchPostsByKeywords","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostKeywordSearchBodyDto"},"examples":{"expanded":{"value":{"concepts":[["CRM","customer relationship management"],["migration","switching"]],"windowDays":7,"limit":25}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostKeywordSearchResponseDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`post_search_not_configured` (`retry: do_not_retry`) — Post search is unavailable. Retrying will not help. Contact support.\n\n`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Search stored LinkedIn posts with explicit keyword groups","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/posts/search/keywords\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"concepts\":[[\"<concepts>\"]]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/posts/search/keywords\", headers=HEADERS, json={\"concepts\":[[\"<concepts>\"]]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/lists":{"get":{"description":"**Required permissions:** `people:read`.\n\nA page of your lists, most recently created first. PAGINATES: follow `nextCursor` until `hasMore` is false.\n\nLists 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.","operationId":"listLists","parameters":[{"description":"Case-insensitive substring match against `name` only.","required":false,"name":"q","in":"query","schema":{"type":"string"}},{"description":"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.","required":false,"name":"updatedSince","in":"query","schema":{"type":"string"}},{"description":"Page size, default 25, max 100.","required":false,"name":"limit","in":"query","schema":{"minimum":1,"maximum":9007199254740991,"type":"integer"}},{"description":"A previous page's `nextCursor`, verbatim.","required":false,"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListListDto"}}}},"400":{"description":"`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.\n\n`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List lists","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/lists\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/lists\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"post":{"description":"**Required permissions:** `people:write`.\n\n**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.\n\n**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.\n\nThe 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.","operationId":"createList","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListDto"}}}},"responses":{"201":{"description":"List created, empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Create a list","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/lists\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"<name>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/lists\", headers=HEADERS, json={\"name\":\"<name>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/lists/{id}":{"get":{"description":"**Required permissions:** `people:read`.\n\nThe list with its live counts. Members themselves are on `GET /v1/lists/{id}/members`, paginated.","operationId":"getList","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Get a list","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl \"$TRAYO_API_URL/v1/lists/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.get(f\"{BASE}/v1/lists/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"patch":{"description":"**Required permissions:** `people:write`.\n\n`name` is the only mutable field. Membership changes go through the `/members` routes, which is what keeps them append-only and safe to retry.","operationId":"updateList","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateListDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDto"}}}},"400":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Rename a list","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X PATCH \"$TRAYO_API_URL/v1/lists/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"<name>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.patch(f\"{BASE}/v1/lists/{id}\", headers=HEADERS, json={\"name\":\"<name>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"delete":{"description":"**Required permissions:** `people:write`.\n\nHard 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.\n\nRefused with `list_in_use` while an outreach play in the Trayo app references the list; that reference is not addressable here.","operationId":"deleteList","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The list as it existed immediately before deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDto"}}}},"400":{"description":"`id` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"409":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Delete a list","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X DELETE \"$TRAYO_API_URL/v1/lists/{id}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.delete(f\"{BASE}/v1/lists/{id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/lists/{id}/members":{"get":{"description":"**Required permissions:** `people:read`.\n\nA page of the list's live members, most recently added first. PAGINATES: follow `nextCursor` until `hasMore` is false.\n\nEach 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.\n\nRows whose person or account has since been deleted, and rows the app has marked `excluded`, are omitted.","operationId":"listListMembers","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"description":"Only members of this kind. Omit for both.","required":false,"name":"kind","in":"query","schema":{"type":"string"}},{"description":"Page size, default 25, max 100.","required":false,"name":"limit","in":"query","schema":{"minimum":1,"maximum":9007199254740991,"type":"integer"}},{"description":"A previous page's `nextCursor`, verbatim.","required":false,"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMemberListDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"List members","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl \"$TRAYO_API_URL/v1/lists/{id}/members\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.get(f\"{BASE}/v1/lists/{id}/members\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"post":{"description":"**Required permissions:** `people:write`.\n\nAdd 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.\n\n**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.\n\nThe typical shape: `POST /v1/find` → `POST /v1/accounts/batch` (or `/v1/people/batch`) → this route with the ids that came back and `via: \"find\"`.","operationId":"addListMembers","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddListMembersDto"}}}},"responses":{"200":{"description":"The request was processed. Check `skipped` — a `200` does not mean every row landed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddListMembersResultDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Add members","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id} with the real value.\ncurl -X POST \"$TRAYO_API_URL/v1/lists/{id}/members\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"members\":[{\"personId\":\"11111111-1111-4111-8111-111111111111\"}]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nr = requests.post(f\"{BASE}/v1/lists/{id}/members\", headers=HEADERS, json={\"members\":[{\"personId\":\"11111111-1111-4111-8111-111111111111\"}]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/lists/{id}/members/{memberId}":{"delete":{"description":"**Required permissions:** `people:write`.\n\nRemoves 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.","operationId":"removeListMember","parameters":[{"name":"id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"memberId","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The membership as it existed before removal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMemberDto"}}}},"400":{"description":"A path parameter is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Remove a member","tags":["Lists"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {id}, {memberId} with the real value.\ncurl -X DELETE \"$TRAYO_API_URL/v1/lists/{id}/members/{memberId}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nid = \"...\"  # the {id} in the path\nmember_id = \"...\"  # the {memberId} in the path\nr = requests.delete(f\"{BASE}/v1/lists/{id}/members/{member_id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/workspace":{"get":{"description":"**Required permissions:** `accounts:read`.\n\nThe 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.\n\n**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.\n\n**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`.","operationId":"getWorkspace","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Read workspace onboarding assumptions and settings","tags":["Workspace"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["accounts:read"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/workspace\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/workspace\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]},"patch":{"description":"**Required permissions:** `settings:write`.\n\nPartial 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.\n\n**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.\"\n\n**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.\n\n**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`.","operationId":"updateWorkspace","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceBodyDto"}}}},"responses":{"200":{"description":"The settings as they now stand.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"409":{"description":"The workspace changed since expectedRevision. Re-read and merge the correction.\n\n`conflict` (`retry: fix_input`) — The generic 409: the request collides with existing state, and none of the specific codes covers it. `message` says what.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Correct workspace onboarding assumptions and settings","tags":["Workspace"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["settings:write"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X PATCH \"$TRAYO_API_URL/v1/workspace\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"expectedRevision\":\"<expectedRevision>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.patch(f\"{BASE}/v1/workspace\", headers=HEADERS, json={\"expectedRevision\":\"<expectedRevision>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/whoami":{"get":{"description":"**Required permissions:** None beyond a valid API key.\n\n**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`.\n\nRead-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.\n\n`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.","operationId":"whoami","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhoamiDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Who this key is: permissions, workspace and limits","tags":["Workspace"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":[]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/whoami\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.get(f\"{BASE}/v1/whoami\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/stakeholders/search":{"post":{"description":"**Required permissions:** `research:trigger`.\n\nGiven 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`.\n\n**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.\n\nPublic REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **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.\n\n**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.\n\n**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.\n\n**Personas.** Each person carries `matchedPersona`, and `coverage` lists every persona with whether anyone in `people` matched it — 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\".\n\n**Repeatable.** Repeating a search within a week normally returns the same people.\n\n**Many companies.** For up to 50 companies at once, use `POST /v1/stakeholders/search/batch` rather than one call per company.\n\n**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.","operationId":"searchStakeholders","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StakeholderSearchBodyDto"}}}},"responses":{"200":{"description":"The selected people, with the company as resolved and the definition as applied. `people` may be empty — read `candidates`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StakeholderSearchResponseDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.\n\n`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`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.\n\n`stakeholder_search_failed` (`retry: retry_later`) — Stakeholder search failed or timed out. Nothing was saved. Send the same request again shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find the stakeholders at a company that fit a definition","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["research:trigger"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/stakeholders/search\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"company\":{\"companyId\":1}}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/stakeholders/search\", headers=HEADERS, json={\"company\":{\"companyId\":1}})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/stakeholders/search/batch":{"post":{"description":"**Required permissions:** `research:trigger`.\n\nThe 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.\n\n**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.\n\nPublic REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. ","operationId":"searchStakeholdersBatch","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StakeholderBatchSearchBodyDto"}}}},"responses":{"200":{"description":"One outcome per submitted company, in `results` or `errors`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StakeholderBatchSearchResponseDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`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.\n\n`stakeholder_search_failed` (`retry: retry_later`) — Stakeholder search failed or timed out. Nothing was saved. Send the same request again shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find the stakeholders at up to 50 companies in one call","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["research:trigger"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/stakeholders/search/batch\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"companies\":[{\"companyId\":1}]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/stakeholders/search/batch\", headers=HEADERS, json={\"companies\":[{\"companyId\":1}]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/research/company":{"post":{"description":"**Required permissions:** `research:trigger`.\n\nGiven 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.\n\nReturns 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. Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. \n\n**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.\n\n**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.","operationId":"researchCompany","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyResearchBodyDto"}}}},"responses":{"200":{"description":"The overview, with the company as resolved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyResearchResponseDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one.\n\n`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`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support.\n\n`research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"A quick overview of a company: what it does, why now, and fit","tags":["Research"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["research:trigger"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/research/company\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"company\":{\"companyId\":1}}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/research/company\", headers=HEADERS, json={\"company\":{\"companyId\":1}})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/research/person":{"post":{"description":"**Required permissions:** `research:trigger`.\n\nGiven 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.\n\nReturns 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. Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. \n\n**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`).\n\n**`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.\n\n**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.","operationId":"researchPerson","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonResearchBodyDto"}}}},"responses":{"200":{"description":"The overview, with the person as resolved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonResearchResponseDto"}}}},"400":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support.\n\n`research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"A quick overview of a person: career, likely challenges, and angles","tags":["Research"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["research:trigger"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/research/person\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"person\":{\"personId\":\"11111111-1111-4111-8111-111111111111\"}}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/research/person\", headers=HEADERS, json={\"person\":{\"personId\":\"11111111-1111-4111-8111-111111111111\"}})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/research/person/batch":{"post":{"description":"**Required permissions:** `research:trigger`.\n\nReturns 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. Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. \n\nSame 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.","operationId":"researchPersonBatch","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonBatchResearchBodyDto"}}}},"responses":{"200":{"description":"One outcome per submitted person, in `results` or `errors`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonBatchResearchResponseDto"}}}},"400":{"description":"`people` empty, over 10, containing an item with neither or both of `personId` / `linkedinUrl`, or a top-level `grounded` field.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support.\n\n`research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"A quick overview for up to 10 people in one call","tags":["Research"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["research:trigger"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/research/person/batch\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"people\":[{\"personId\":\"11111111-1111-4111-8111-111111111111\"}]}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/research/person/batch\", headers=HEADERS, json={\"people\":[{\"personId\":\"11111111-1111-4111-8111-111111111111\"}]})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/contacts/lookup":{"post":{"description":"**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.\n\nGive 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.\n\n**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).\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**`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.\n\n**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.\n\n**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`.","operationId":"lookupContact","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactLookupBodyDto"}}}},"responses":{"200":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactLookupResultDto"}}}},"202":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactLookupResultDto"}}}},"400":{"description":"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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"402":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"503":{"description":"`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.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Find an email or phone for a person you describe","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"contact-fields","parameter":"body.fields","combine":"union","cases":{"email":["people:enrich_email"],"phone":["people:enrich_phone"]},"defaultFields":["email"],"omitted":["people:enrich_email"],"malformed":[]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl -X POST \"$TRAYO_API_URL/v1/contacts/lookup\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"fullName\":\"<fullName>\"}'"},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nr = requests.post(f\"{BASE}/v1/contacts/lookup\", headers=HEADERS, json={\"fullName\":\"<fullName>\"})\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/contacts/lookups/{lookupId}":{"get":{"description":"**Required permissions:** `people:enrich_phone`.\n\nThe 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.\n\nThis response does not include the person. Keep a note of who each `lookupId` refers to.\n\nTrayo 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.","operationId":"getContactLookup","parameters":[{"name":"lookupId","required":true,"in":"path","description":"From a `POST /v1/contacts/lookup` response.","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The lookup, pending or final.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactLookupStatusDto"}}}},"400":{"description":"`lookupId` is not a UUID.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"401":{"description":"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.\n\n`unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"403":{"description":"The key grant and its owner's current workspace role must both include every scope required by the operation.\n\n`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ErrorDto"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"$ref":"#/components/schemas/ScopeDenialDetailsDto"}}}}}]}}}},"404":{"description":"`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"429":{"description":"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.\n\n`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.","headers":{"RateLimit-Limit":{"description":"Maximum requests permitted in the active rolling window.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests remaining in the active rolling window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the oldest request in the window expires.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}},"500":{"description":"Something failed on our side. Nothing about the request needs changing.\n\n`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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"summary":"Collect a lookup that was still running","tags":["People"],"x-trayo-scope-requirements":{"version":1,"requirement":{"kind":"static","scopes":["people:enrich_phone"]}},"x-codeSamples":[{"lang":"shell","label":"curl","source":"# Replace {lookupId} with the real value.\ncurl \"$TRAYO_API_URL/v1/contacts/lookups/{lookupId}\" \\\n  -H \"Authorization: Bearer $TRAYO_API_KEY\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['TRAYO_API_KEY']}\"}\n\nlookup_id = \"...\"  # the {lookupId} in the path\nr = requests.get(f\"{BASE}/v1/contacts/lookups/{lookup_id}\", headers=HEADERS)\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/":{"get":{"description":"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.\n\nServed at both `/` and `/v1`.","operationId":"getApiIndex","parameters":[],"responses":{"200":{"description":"The index."}},"security":[],"summary":"Where the documentation is and how to authenticate","tags":["Meta"],"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\n\nr = requests.get(f\"{BASE}/\")\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/recipes":{"get":{"description":"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.\n\nThe 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.","operationId":"listRecipes","parameters":[],"responses":{"200":{"description":"The recipes."},"429":{"description":"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.\n\n`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.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"security":[],"summary":"The call sequences that make up a task","tags":["Meta"],"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/recipes\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\n\nr = requests.get(f\"{BASE}/v1/recipes\")\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/v1/openapi.yaml":{"get":{"description":"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.\n\nThe 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.","operationId":"getOpenApiDocument","parameters":[],"responses":{"200":{"description":"This OpenAPI document.","content":{"application/yaml":{"schema":{"type":"string"}},"application/json":{"schema":{"type":"object"}}}},"429":{"description":"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.\n\n`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.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorDto"}}}}},"security":[],"summary":"This document","tags":["Meta"],"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/v1/openapi.yaml\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\n\nr = requests.get(f\"{BASE}/v1/openapi.yaml\")\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/openapi.json":{"get":{"description":"The same document as `GET /v1/openapi.yaml`, serialized as JSON. Send `Accept: application/yaml` to get YAML from this URL instead.","operationId":"getOpenApiDocumentJson","parameters":[],"responses":{"200":{"description":"This OpenAPI document.","content":{"application/json":{"schema":{"type":"object"}},"application/yaml":{"schema":{"type":"string"}}}}},"security":[],"summary":"This document, as JSON","tags":["Meta"],"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/openapi.json\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\n\nr = requests.get(f\"{BASE}/openapi.json\")\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}},"/.well-known/openapi":{"get":{"description":"Identical to `GET /openapi.json`.","operationId":"getWellKnownOpenApiDocument","parameters":[],"responses":{"200":{"description":"This OpenAPI document.","content":{"application/json":{"schema":{"type":"object"}},"application/yaml":{"schema":{"type":"string"}}}}},"security":[],"summary":"This document, at its well-known URI","tags":["Meta"],"x-codeSamples":[{"lang":"shell","label":"curl","source":"curl \"$TRAYO_API_URL/.well-known/openapi\""},{"lang":"python","label":"Python (requests)","source":"import os\nimport requests\n\nBASE = os.environ.get(\"TRAYO_API_URL\", \"https://api.trayo.ai\")\n\nr = requests.get(f\"{BASE}/.well-known/openapi\")\nbody = r.json()\nif not r.ok:\n    err = body[\"error\"]\n    raise SystemExit(f\"{r.status_code} {err['code']} ({err['retry']}): {err['message']}\")\nprint(body)"}]}}},"info":{"title":"Trayo API","description":"**Permissions:** each authenticated REST operation publishes `x-trayo-scope-requirements`\nand a readable summary of its required permissions. MCP checks permissions per tool;\nread each tool’s `_meta[\"trayo/scopeRequirements\"]`. Read `GET /v1/whoami` for your\nkey’s configured scopes, current effective permissions and restrictions.\nRequest validation, workspace access rules and plan eligibility still apply.\n\nDefine accounts (companies) and signals (what you want to detect), run\ndiscovery on demand, read the events discovery produces.\n\n**Start with `GET /v1/whoami`.** It confirms the key and returns the\nworkspace, the plan, `canInitiate` and the lookup\nlimits — the numbers to plan against before you start. Then pick\na **recipe**: the call sequences that make up a task (track companies and\nread events; find lookalike companies; find and keep prospects; stakeholders at a company with\ncontact details; a brief before outreach; recent movers) are published at\n`GET /v1/recipes`, carried in this document as `x-recipes`, and rendered\nin `/llms.txt`. Each step names the operation, what it does in that\nsequence, and for a poll step the condition that ends the loop. Every\noperation below also carries `x-codeSamples` — a `curl` and a Python\nsample generated from its own parameters and request schema.\n\nCreate or reuse accounts and signals. Then call `POST /v1/discoveries` with the account IDs, signal keys, and lookback window.\nAdd `people: \"add\"` when you want the people who matter to each event added and listed on it; by default a run adds no people.\n\n**Give a new account the strongest identity you have.** Send its bare\n`linkedinHandle` whenever you know the LinkedIn company page; it is checked\nbefore the website `url`, then the name. A correct handle at create time\nmakes a `company_not_found` result less likely.\n\n**Discovery is ASYNCHRONOUS.** The POST returns 202 immediately with a run\nwhose `status` is `running` — it has not searched anything yet. Poll\n`GET /v1/discoveries/{runId}` until `settledAt` is non-null, then read\nthe events standing for its scope with `GET /v1/events?discoveryRunId={runId}`.\nThat scope is everything the run wrote — at any event date, because a job\nchange is dated by the month the job STARTED and can predate the lookback —\nplus the canonical events matching the run's accounts, signals and lookback\nwindow, including ones earlier runs wrote. Paged to the end, a settled run\ntherefore yields at least `eventsNew` events — one page is capped and need\nnot, so follow `nextCursor` before comparing anything to that counter.\nReading it before `settledAt` returns what the run has written so far.\n\n**Run your signals together, not one at a time.** Every event carries\n`signalKeys`, the keys of the signals that matched it, so a single run over\nmany signals stays attributable event by event. Do not split a run per\nsignal to find out which one fired. `signalType` will not tell you — it is\nthe kind of source a signal reads (`news`, `jobs`, `job_change`) and your\nsignals share a handful of those between them.\n\n**`settledAt` is the finish line.** A run has two halves — searching your\naccounts, then writing what the search found as events — and it is final\nonly when both are done. `settledAt` is set at that instant, `status` reads\n`completed` or `failed` at the same instant, and from then on the candidate\nand event counts are frozen: they are the run's receipt and stay what they\nwere even if you later delete some of the events it created.\n\n`completedAt` marks only the first half, the search. It can be set while\n`status` is still `running`, because the events from that search are still\nbeing written. **Do not poll on `completedAt`, and do not try to detect the\nend by watching `eventsNew` stop changing** — a pause between two writes\nlooks exactly like the end of a run and is not one. Wait for `settledAt`.\n\n`status` is the POLLING signal: it tells you when to stop calling, nothing\nmore. `candidatesScreened`, `candidatesMatched` and `eventsNew` are the RUN\nRECEIPT — what this execution screened, what matched a signal, and what it\nwrote — not the size of the scoped events page. Candidates are not events:\n`candidatesScreened: 12` with `candidatesMatched: 0` means nothing matched,\nand `bySignal` gives both counts per signal, so a signal that matched nothing\nshows 0. `eventsFound` is the deprecated name for `candidatesScreened`. Read the page,\n`error`, and `blockedSignals`; even a `completed` run with a non-null `error`\nended without doing everything it was asked to.\n\n**A run also says what it could NOT search.** `blockedSignals`, on the 202\nand on every poll, names each ready signal that cannot produce results for\npart of the run, why, and — in `accountIds` — WHICH accounts. Read it before\nconcluding from `eventsNew: 0` that there was nothing to find: an account\nthat was not searched is indistinguishable from a real empty result on the\ncounts alone.\n\nYou can use a new signal key in discovery immediately.\nThese 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.\n\n**Re-running an identical discovery is safe.** Results are deduplicated\nagainst everything already in the workspace, so calling the same\naccounts/signals/lookback again typically returns `eventsNew: 0` and creates\nno duplicate events. The `discoveryRunId` event read still returns the\nmatching events that already stand for that scope. Zero is SUCCESS, not\nfailure, and not a reason to retry. A retry loop that treats `eventsNew: 0`\nas an error will never terminate.\n\n**Errors** are always `{ \"error\": { type, code, retry, message, param,\nrequestId } }`. `code` identifies the reason from the documented set.\nEvery failure includes a code, including request validation failures.\n`retry` tells you what to DO about it without parsing the message:\n`fix_input` (change the request), `retry_later` (send the same request again\nshortly, with backoff and a FINITE number of attempts), `do_not_retry` (it\nwill never work). Branch on `code`; fall back to `retry` when you only need\nto decide whether to send the request again, or when you meet a `code` you\ndo not recognise — that fallback is what lets a client written today keep\nworking against an error added tomorrow. The full table, with every code's\nstatus and retry class, is in the `Error` schema.\n\nTwo 409s make the point: `idempotency_in_progress` and\n`idempotency_key_reused` share a status and demand OPPOSITE responses —\nrepeat the identical request with the same key, versus never send that\nrequest again unchanged. Never infer recovery from the HTTP status.\n\n**Rate limits.** Every route shares the workspace budget, in two buckets, each a\nrolling 60-second window. A workspace starts with 120\nrequests per minute for the API and a separate\n7 per minute for the routes\nthat 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\nbucket or the other, never both, and each MCP tool names the bucket it spends in its description and\n`_meta[\"trayo/rateLimitBucket\"]`. REST, MCP, all API keys and OAuth users share\nthe same workspace buckets; adding credentials does not increase throughput.\nSupport can adjust these defaults for a workspace, so read your budget rather than\nhardcoding these numbers: EVERY authenticated response, successful ones included, carries\n`RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds\nuntil the window frees up) for the bucket that request was counted in. That\nis how you slow down BEFORE being refused. Over the limit is\n`429 rate_limited` with `Retry-After` in seconds: wait that long, then send\nthe same request again.\n\n**Fanning out is what actually hits this** — a hundred concurrent\ncalls to any route are a hundred requests in one window, not one — and\n**`GET /v1/whoami` is limited like everything else**, so a\nbalance poll on a timer spends the same budget as the work it is watching.\nBack the poll off with the rest of your client, or drop it: the\n`RateLimit-*` headers on the responses you are already getting say more,\nsooner, for nothing. A per-network limit also sits in front of every `/v1`\nroute, before the key is read: a burst from one network can be refused\nthere, and that 429 carries `Retry-After` and no `RateLimit-*`. It is the\nonly limit on the unauthenticated document routes under `/v1` — fetch the\ndocument once and reuse it.\n\n**Usage limits.** Public REST and MCP usage quotas and daily usage caps are\ndisabled for now. Lookup allowances report `null` in `GET /v1/whoami`\n(MCP: `trayo_whoami`). Usage is still recorded; workspace request pacing still applies.\nAuthentication, permissions, workspace lifecycle and request validation still apply.\n\n**Pagination.** Six list routes — `GET /v1/accounts`, `GET /v1/people`,\n`GET /v1/events`, `GET /v1/discoveries`, `GET /v1/lists`,\n`GET /v1/lists/{id}/members` — return `{ data, hasMore,\nnextCursor }`; follow `nextCursor` until `hasMore` is false.\n`POST /v1/find` also paginates when you send only `filters`: repeat the\nsame request with `nextCursor` as `cursor`, preserving the filters, until\n`hasMore` is false. Any search with a `query`, including one that also\nsends `filters`, returns one ranked page and rejects a `cursor`.\n`GET /v1/signals`, `POST /v1/stakeholders/search` and the research routes\ndo not paginate. `GET /v1/signals` carries the list envelope with\n`hasMore` always false. `POST /v1/job-changes/search` pages with its own\n`limit`/`cursor`/`nextCursor` on the body, not on the query string, and\nonly when you send `destination`.\n\n**Naming across routes:** two things go by different names depending on\nthe route, and nothing is renamed for you — map them at your boundary.\nThe **company website** is `url` on an account, `domain` on the `company`\nblock of `POST /v1/people`, and `website` on the routes that take a company\nreference (`POST /v1/research/company`, `POST /v1/stakeholders/search`,\n`POST /v1/job-changes/search`, `POST /v1/contacts/lookup`); a\n`POST /v1/find` accounts row carries both `domain` and `url`. The\n**LinkedIn page** is a bare handle in an account's `linkedinHandle` and in\na person's own `linkedinUsername`, and a full URL in `linkedinUrl` on the\ncompany references and on find results. Different names, the same company\nwebsite and the same LinkedIn page.\n\n**Prospecting:** `POST /v1/find` searches for companies and people.\nFor direct stored-data search, send `mode: \"direct\"` with exact `filters`\nand a limit up to 100. For people, company filters select their current\nemployer and `filters.title` selects the person; each row includes both.\nDirect mode does not accept a sentence query or automatically expand criteria.\nA company without a website is still returned; use its ID for follow-up searches.\nCheck `truncated` and `notes`; per-company limits can leave additional people unseen.\n`POST /v1/posts/search/keywords` directly searches stored LinkedIn posts:\n`concepts` is an AND of groups, each containing OR phrase alternatives.\nThis supports explicit query decomposition and synonym expansion in one call.\nIts results rank keyword relevance within a bounded sample of 200 ranked candidates, with coverage even when empty.\nIt searches at most seven days of the recent-post index (bodies of at least 120 characters).\nRetrieval is always non-exhaustive; an empty result cannot prove absence.\nDirect searches use an eight-second request budget and shared capacity limits.\nOn `data_search_busy`, wait for `details.retryAfterSeconds`; on\n`data_search_timeout`, narrow the criteria. Neither error means no matches.\n`POST /v1/accounts/lookalikes` finds companies similar to supplied seeds.\nBoth return candidates without saving them — you post selected results\nback to `/v1/accounts/batch` or `/v1/people/batch` to bring them in.\n`POST /v1/companies/competitors` ranks the competitors of one company.\nFind with a sentence query draws on your search quota per search that runs; contact lookups (below)\ndraw on your plan's lookup allowance. Research, stakeholder search and\nasking about a person draw on the same usage quota per answer returned.\nNothing else in this API is metered.\n\n**Working sets:** `/v1/lists` is where the set of people or accounts you\nare acting on lives between calls. Create one, add members by `personId`\nor `accountId` with a `via` that records how they got there (`find`,\n`signal`, `import`, `manual`), then hand the LIST — not your session — to\nwhatever acts on it: a CRM push, a campaign, a human. Adding a member who is\nalready there is a skip, not an error, so the same batch is safe to send\ntwice. Lists are scoped to the user your key acts as, and show up in the\nTrayo app under that user. Do not mark a working set with `starred`, a\nperson's `note`, or a marker in `title` — that is what lists are for.\n\n**Ranking:** every account carries `scores` — Trayo's own view of how\nmuch is happening there — so you do not have to rebuild a ranking by\ncounting events. `scores.timing.score` (0–100, normalized against your\nworkspace's hottest account, as the product defines it) is the number for\n\"who is worth working this week\"; `scores.timing.raw` is the un-normalized\nvalue that holds still for absolute thresholds;\n`scores.activity` adds the event count, average significance and relevance,\nand a generated prose `summary` of what the events add up to. Both are\nnull until Trayo has scored the account, and nothing in this API\ntriggers scoring. `GET /v1/accounts` does not sort by them yet: page\nthrough and sort client-side.\n\n**Contact enrichment:** `POST /v1/people/{id}/enrich` finds a person's\nemail and/or phone the same way the Trayo app's own \"Find email\" does,\nand `POST /v1/people/enrich/batch` accepts up to 200 ids per call. Its\n45-second budget for starting lookups is checked once, before anything is\nreserved; the whole batch starts together in a few seconds. The batch answers `200`\nwith partial results. Retain the submitted ids: `skipped[].index` maps to\nthat request. Re-send rows skipped because time ran out in smaller batches;\nnothing was started or charged for them. Other skip reasons, including\nallowance refusals and invalid ids, require their own remedy. Single-person\nlookup requests answer `202`; both routes use `GET /v1/people/{id}/enrich` to poll.\nThese lookups are\nexplicit (nothing is looked up on create or read), asynchronous, idempotent\n(re-sending never double-runs or is charged twice — a field already\nrunning, already found, blocked by an email you wrote, or inside its retry\ncooldown is reported in `skipped`, not raised), and governed by your plan:\neach lookup counts against the\nplan's lookup limit (`402 lookup_limit_reached` before anything runs).\nEvery found value carries\n`source`, `status` and `foundAt`; a value you wrote is `source: \"manual\"`\nand is never overwritten by a found one.\n\n**Workspace settings:** `PATCH /v1/workspace` sets your stakeholder\ndefinition — plain prose describing who buys and owns what you sell, and\nwho to skip — plus `solutions` and `solutionsBrief`, what you sell. The\ndefinition steers stakeholder search and the per-event people (with\n`reasoning`) that a discovery run started with `people: \"add\"` attaches;\nthe default, `people: \"none\"`, attaches none. Without it stakeholder search seeds\non seniority alone and no people are attached to events. **Set it FIRST,\nbefore you run discovery** — it applies to work\nthat starts after it is set, not retroactively. The definition is carried as\n`stakeholderCriteria` on the workspace and as `definition` on stakeholder\nsearch.\n\n**Stakeholders:** `POST /v1/stakeholders/search` takes a company — any\ncompany by its website, or an account of yours — and a stakeholder definition,\nand returns the people at it who fit, best first, each with a reason.\nSynchronous, seconds, read-only: nothing is written, import the ones you\nwant with `POST /v1/people/batch`. It is the product's own stakeholder\nfinding (definition → keywords → candidate search → selection) without the\npersistence, so it works for a company you have never touched. Omit the\ndefinition to use the workspace's; the response says which applied.\nEach person carries `matchedPersona` and each company `coverage` (every\npersona, whether anyone matched it, and `candidates`: how many people its own\ntitle search found, 0 meaning nobody there holds such a role); each label is checked against the\njob's function and seniority. When nobody fits, `nearMisses` holds the\nclosest candidates flagged `belowThreshold: true`. Repeating a search\nwithin a week normally returns the same people. For many companies,\n`POST /v1/stakeholders/search/batch` takes up to 50 in one call and searches\nthem ten at a time, reporting each in `results` or `errors` by `index`.\n\n**Recent moves:** `POST /v1/job-changes/search` finds people who changed\ncompany. Send `source` (the company left), `destination` (the company\njoined), or both. Without a company, send `title` (the new role, same\nterms as `/v1/find` `filters.title`), optionally with `destinationFilters`\n(`hq.countries`, `headcount` of the company joined). Inclusive `startedSince` and\nexclusive `startedBefore` bound the start month for either kind of search. `startedSince` (the\nmonth the new role started) narrows either kind of search: \"who became\nCISO since August at US companies over 500 people\" is one call, and so is\n\"who joined Stripe as an executive since September\". It returns moves\nobserved in the last 90 days — the person, the two companies, the title on\neach side, when the move was detected, and when the new role started to the\nmonth — and writes nothing. One row per person per destination.\n`limit` sets the page size: 25 by default, 100 at most, capped rather than\nrejected. `hasMore` says more matches may remain behind this page; send\n`nextCursor` back as `cursor`, verbatim and with the same search, to walk\nthem. The walk resumes where the page stopped, so a later page neither\nrepeats nor skips what an earlier one returned. `hasMore` reads the row\ncount before duplicate records are collapsed, so a page can come back\nshort and still be `true` — follow `hasMore` and `nextCursor`, never the\nrow count. A source-only search may read a fixed sample rather than an ordered\nwalk: only that sampled search cannot page, and sending a cursor is rejected.\nIndexed source-only searches accept cursors. For a sampled source-only search,\n`nextCursor` is null, and `hasMore: true` means narrow the\nsearch. Report truncation\nfor the requested period. `detectedSince` filters detection recency: only moves with\n`detectedAt >=` the value you send — the date the move was DETECTED, not the\ndate the role started, which startedSince and startedBefore filter. It only narrows the 90-day\nwindow, starting at midnight UTC 90 days ago: an earlier value is clamped to\nthat start and a future value matches nothing. Destination searches already return\nthe newest moves first: narrowing the window does not recover omitted matches;\nit returns the same rows or a subset. This is a recency filter, not pagination.\n`seniority` is the second filter: it keeps only moves INTO a role at that\nlevel or above, read from the title of the role they joined — not the one\nthey left, and not a title list you supply. The four seniority levels widen\nin order: `cxo` (the C-suite), `vp` (adds VP, SVP, EVP, Vice President and\nPresident), `director` (adds Director), `head` (adds Partner, Principal and\nHead of). With `destination`, it WIDENS the search rather than trimming the page — the search\nreads further through recent moves looking for matches instead of filtering\nthe slice it would have read without it — and that wider read is still\nbounded, so a short or empty answer does not prove nobody senior joined.\nSource-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.\n`sampled: true` means a source-only search read a small\nfixed sample of recent moves: an empty answer does not mean nobody left,\nand retrying gives the same answer while the underlying data is unchanged.\nA source-only search with `sampled: false` walked the newest moves out of\nthat company instead of sampling them.\nWith `destination`, `sampled` is false: the search reads a bounded slice of\nthe newest moves into that company before filtering by source.\n`hasMore: false` does not prove completeness with either value of `sampled`.\nEvery row is a move between two companies; promotions and same-company\ntitle changes never appear here. For promotions, and for checks you repeat on a\ncompany over time, use the workspace `job_change` signal (`create_signal`, then\n`run_discovery`). That signal is not a departure search and does not provide\na complete list of who left. Import the people you want with\n`POST /v1/people/batch`.\n\n**Research:** `POST /v1/research/company` and `POST /v1/research/person`\nreturn the quick overview the Trayo app opens its drawer with — for a\ncompany: what it does, `whyNow` hooks from its recent updates, and `fit`\nagainst what you sell; for a person: career, likely challenges, and the\nangles that tie their role to what you sell. Synchronous, stateless,\nread-only: one read of Trayo's own data, one generated answer, nothing stored,\nnothing written, any company or person we hold — not only the ones in your\nworkspace. Both read your `solutions` and stakeholder definition from the\nworkspace for the seller side of the answer.\n\n`POST /v1/research/person/batch` is the person route for up to\n10 people in one call, always ungrounded, reading your seller\ncontext once for the whole batch. A person it cannot resolve or answer for\nis reported in `errors` by its `index`, with the same `code` and `retry` an\nerror response carries, so one bad profile never costs you the rest of the\nbatch. An overview that came back empty is one of those errors\n(`person_profile_too_thin`), never a blank result.\n\n**Contact lookup without a person:** `POST /v1/contacts/lookup` takes a name\nplus a LinkedIn profile or a company website and returns that person's email\nin the same response, for somebody who is not in your workspace and does not\nhave to be. Ask for `fields: [\"phone\"]` and it answers `202` with a\n`lookupId` to collect from `GET /v1/contacts/lookups/{lookupId}` — the phone\nlookup can take several minutes. One lookup per\nfield, against your plan's allowance for that field — email and phone have\nseparate limits, and a lookup here draws on the same allowance as a lookup\non a person. Asking again for the same person reads the stored answer for\nnothing.\n\n**Ask about a person:** `POST /v1/people/{id}/ask` answers one question\nabout one person in your workspace from everything on record — the Trayo\nrecord (contact details, the account and its research, the signals the\nperson is attached to and why, the sequences and emails sent with their\noutcomes, lists, notes) plus the LinkedIn profile Trayo holds for them\n(work history, recent posts). Send an `intent` — what you will do with the\nanswer — to steer focus and form. Synchronous, seconds, read-only,\ngrounded in that dossier only: nothing is searched, nothing is guessed,\nand `sources` is a receipt of what Trayo read. Each answer draws on\nyour usage quota.\n\n**Which of the two to ask.** `POST /v1/research/person` is a FIXED overview\nof ANY person Trayo holds, built from their public profile — use it for\nsomeone you have just found and do not have. `POST /v1/people/{id}/ask`\ntakes YOUR question about a person you already hold, and is the only route\nthat can see your own history with them: what discovery attached to them,\nwhat you sent, whether they replied. \"Who is this person\" is research;\n\"did we ever email her, and is she worth a sequence\" is ask.\n\n**Limits go two ways, on purpose.** `POST /v1/find`'s `limit` CLAMPS at 50.\nA `/v1/…/batch` array over its cap is an ERROR — 200 items on the write\nbatches, 10 on `POST /v1/research/person/batch`. Silently dropping\nbatch rows would lose records you believe you created; rejecting an over-large\nfind `limit` would draw on your search quota for a number you could have read\nhere.\n\n**Auth:** every route below requires `Authorization: Bearer <key>` except\na few unauthenticated discovery routes, so a caller can find and read\nthis document before it has a key configured: `GET /` and `GET /v1` return\na small JSON index naming the document and how to authenticate, and this\ndocument itself is served at `GET /openapi.json`,\n`GET /.well-known/openapi` and `GET /v1/openapi.yaml`. All three are the\nSAME document; the extension picks the default serialization and an\n`Accept` header of `application/json` or `application/yaml` overrides it.\nThe same reference as Markdown is at `GET /llms.txt` (a short index) and\n`GET /llms-full.txt` (everything, in one file); `GET /v1/recipes` is\nunauthenticated too.","version":"1.0.0","contact":{}},"tags":[{"name":"Accounts","description":"Companies you track. Discovery runs against accounts, and each one carries `scores` — Trayo's ranking of how much is happening there."},{"name":"Signals","description":"Named definitions of what counts as a noteworthy event. A discovery run scans for events matching your ready signals."},{"name":"Discoveries","description":"On-demand runs that scan accounts for signal matches and produce events. Asynchronous: start one, then poll it until `settledAt` is set."},{"name":"Events","description":"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."},{"name":"People","description":"Contacts at accounts you track — and the lookups that find their emails and phones, with provenance."},{"name":"Lists","description":"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."},{"name":"Find","description":"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`."},{"name":"Workspace","description":"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."},{"name":"Research","description":"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."},{"name":"Meta","description":"Machine-readable API description: this document, the index, and the recipes. Unauthenticated."}],"servers":[{"url":"https://api.trayo.ai"}],"components":{"securitySchemes":{"bearerAuth":{"scheme":"bearer","bearerFormat":"JWT","type":"http","description":"A single static key, e.g. `Authorization: Bearer sk_live_...`. The key implicitly identifies your workspace and an owning user — there is no separate workspace id to pass anywhere."}},"schemas":{"ErrorDto":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["invalid_request_error","authentication_error","authorization_error","not_found_error","conflict_error","api_error"],"description":"Coarse class, derived directly from the HTTP status (400 -> invalid_request_error, 401 -> authentication_error, 403 -> authorization_error, 404 -> not_found_error, 409 -> conflict_error, anything else including a 500 -> api_error). The HTTP status is in turn fixed by `code`, per the table below — status, `type`, `code` and `retry` are always mutually consistent, so no response can pair a 404 with advice to retry it."},"code":{"type":"string","enum":["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","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"],"description":"Why the request failed. This enum lists the codes that the API currently returns.\nEvery `/v1` failure includes a code and retry instructions.\n\nUse `code` to identify the failure and `retry` to decide what to do next.\nIf you do not recognize a code, follow its `retry` value.\n\nThe status and recovery class each code is served with:\n\n| code | status | retry | what it means / what to do |\n\n| --- | --- | --- | --- |\n\n| `data_search_busy` | 503 | `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. |\n\n| `data_search_timeout` | 503 | `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. |\n\n| `validation_failed` | 400 | `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. |\n\n| `malformed_cursor` | 400 | `fix_input` | `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over. |\n\n| `invalid_date` | 400 | `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. |\n\n| `unknown_account` | 400 | `fix_input` | One or more `accountIds` do not exist in your workspace. `message` lists them. Create them first or drop them from the request. |\n\n| `unknown_signal` | 400 | `fix_input` | One or more `signalKeys` do not exist in your workspace. `message` lists them. |\n\n| `unauthorized` | 401 | `do_not_retry` | Missing, malformed, or incorrect bearer token. Identical body for every cause. |\n\n| `insufficient_scope` | 403 | `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. |\n\n| `forbidden` | 403 | `do_not_retry` | The API key is valid but lacks a required scope. `message` identifies the missing scope to request; retrying with the same key will not help. |\n\n| `not_found` | 404 | `do_not_retry` | The generic 404: a resource that does not exist and that none of the specific codes covers. `message` says which. |\n\n| `account_not_found` | 404 | `do_not_retry` | No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one. |\n\n| `signal_not_found` | 404 | `do_not_retry` | No such signal key, or it is product-managed/person-entity and not addressable here. |\n\n| `event_not_found` | 404 | `do_not_retry` | No such event in your workspace, or it has not finished processing and is not addressable yet. |\n\n| `discovery_run_not_found` | 404 | `do_not_retry` | No such run in your workspace. |\n\n| `person_not_found` | 404 | `do_not_retry` | No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one. |\n\n| `list_not_found` | 404 | `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. |\n\n| `discovery_not_found` | 404 | `do_not_retry` | No such discovery in your workspace. Another workspace's discovery is indistinguishable from a non-existent one, and a discovery is removed 30 days after it starts. |\n\n| `list_member_not_found` | 404 | `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. |\n\n| `method_not_allowed` | 405 | `fix_input` | This path exists but does not accept the method you used. The `Allow` RESPONSE HEADER lists the methods it does accept and `message` names the call to make instead. THE RESOURCE IS REAL — do not treat this like a 404 and give up on it. |\n\n| `idempotency_in_progress` | 409 | `retry_later` | This operation is still in progress. Retry the same request with the same Idempotency-Key. |\n\n| `conflict` | 409 | `fix_input` | The generic 409: the request collides with existing state, and none of the specific codes covers it. `message` says what. |\n\n| `signal_key_conflict` | 409 | `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. |\n\n| `idempotency_key_reused` | 409 | `fix_input` | This `Idempotency-Key` was already used with a different request body. Repeating this request unchanged will keep failing; use a new key. |\n\n| `list_in_use` | 409 | `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. |\n\n| `quota_daily_limit` | 429 | `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. |\n\n| `quota_exceeded` | 402 | `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. |\n\n| `lookup_limit_reached` | 402 | `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. |\n\n| `lookup_start_failed` | 503 | `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. |\n\n| `contact_lookup_not_found` | 404 | `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. |\n\n| `phone_lookup_failed` | 503 | `retry_later` | Accepted phone work failed. GET polling returns HTTP 200 with status failed and this nested reason. Stop polling this ID; retry a new POST later. No automatic refund. |\n\n| `phone_lookup_expired` | 503 | `retry_later` | Accepted phone work exceeded its supported lifetime. GET polling returns HTTP 200 with status failed and this nested reason. Stop polling this ID; retry a new POST later. No automatic refund. |\n\n| `contact_lookup_failed` | 503 | `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. |\n\n| `company_not_found` | 404 | `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`. |\n\n| `company_resolution_timed_out` | 503 | `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. |\n\n| `too_many_names` | 429 | `retry_later` | A company resolve entry past the first 25 names of one request. Trayo did not match it, and the other entries are answered. Send it again in another request. |\n\n| `job_change_search_not_configured` | 503 | `do_not_retry` | Job-change search is unavailable. Retrying will not help. Contact support. |\n\n| `job_change_search_failed` | 503 | `retry_later` | The bounded lookup failed or ran past its time budget. Nothing was written. Send the same job-change request again shortly. |\n\n| `lookalike_search_not_configured` | 503 | `do_not_retry` | Lookalike search is unavailable. Retrying will not help. Contact support. |\n\n| `lookalike_search_failed` | 503 | `retry_later` | Lookalike search failed or exceeded its time budget. Nothing was saved; retry the same request shortly. |\n\n| `competitor_roster_not_configured` | 503 | `do_not_retry` | Competitor rosters are unavailable in this environment. Retrying will not help. Contact support. |\n\n| `competitor_roster_failed` | 503 | `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. |\n\n| `post_search_not_configured` | 503 | `do_not_retry` | Post search is unavailable. Retrying will not help. Contact support. |\n\n| `post_search_failed` | 503 | `retry_later` | The post search or engager read failed or exceeded its time budget. Nothing was saved; retry the same request shortly. |\n\n| `job_search_not_configured` | 503 | `do_not_retry` | Job posting search is unavailable. Retrying will not help. Contact support. |\n\n| `job_search_failed` | 503 | `retry_later` | The job posting search could not read the data or got no answer within its time budget. Nothing was saved; retry the same request shortly. A search that itself ran out of time is `data_search_timeout` instead. |\n\n| `stakeholder_search_not_configured` | 503 | `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. |\n\n| `stakeholder_search_failed` | 503 | `retry_later` | Stakeholder search failed or timed out. Nothing was saved. Send the same request again shortly. |\n\n| `stakeholder_search_not_found` | 404 | `do_not_retry` | No stakeholder search with this `searchId` for your credential. A batch search can be read for an hour after it starts, only with the credential that started it. Start the search again. |\n\n| `person_ask_not_configured` | 503 | `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}`. |\n\n| `person_ask_failed` | 503 | `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`. |\n\n| `person_profile_not_found` | 404 | `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. |\n\n| `person_profile_too_thin` | 404 | `fix_input` | The profile we hold for this person has too little in it for an overview built from it alone, so the overview came back empty. Sending the same request again gives the same result. Research this person on their own with `grounded: true`, which also searches the web. |\n\n| `research_not_configured` | 503 | `do_not_retry` | Company and person research are unavailable. Retrying will not help. Contact support. |\n\n| `research_failed` | 503 | `retry_later` | A research route failed or ran past the time budget. Nothing was written. Send the same request again shortly. |\n\n| `find_needs_indexed_filter` | 400 | `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`, `filters.fundingStages` or 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. |\n\n| `rate_limited` | 429 | `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. |\n\n| `internal_error` | 500 | `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. |\n\n| `find_not_configured` | 503 | `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. |\n\n| `find_response_failed` | 500 | `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. |\n\n| `discovery_run_too_large` | 400 | `fix_input` | The run asked for more accounts or signals than a run started through MCP covers. `details.requested` and `details.limits` carry the numbers. Split it into smaller runs, or start one larger run with `POST /v1/discoveries`, which accepts the bounds in `details.rest`. |\n\n| `mcp_result_delivery_failed` | 503 | `do_not_retry` | An MCP operation completed but its result file could not be saved. Do not automatically repeat a search: it may incur another charge. Read-only listings can be requested again with a smaller page and inline output. |\n\n| `mcp_result_not_found` | 404 | `do_not_retry` | No stored MCP result with this `resultId` is available to your credential. A result can be read only by the API key or OAuth user that created it, in the same workspace. Results are also removed some time after they expire. |\n\n| `mcp_result_expired` | 404 | `do_not_retry` | The stored MCP result has expired. Results can be read for four hours, or until the API key expires if that is sooner. Do not automatically repeat the original operation: a new search may incur another charge. Ask the user before running it again. |\n\n| `mcp_tool_removed` | 404 | `do_not_retry` | MCP only. The tool you called was removed from the MCP server. `message` names the tool that replaces it and how to move your arguments to it. Calling the removed name again, with any arguments, gives the same error; refresh your tool list. |\n\n| `model_unavailable` | 503 | `retry_later` | The step that judges or writes this answer failed. Nothing was charged. Wait, then send the same request again. |\n\n| `find_unavailable` | 503 | `retry_later` | The search service failed. Wait, then send the same request again. A successful search with incomplete data returns 200 with `degraded: true`. |\n\n| `find_timeout` | 503 | `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. |\n\nNote the two 409s — `idempotency_in_progress` and\n`idempotency_key_reused`: they share a status and carry\nOPPOSITE instructions. The first is `retry_later` — send the identical\nrequest again with the same key. The second is `fix_input` — that exact\nrequest will never succeed, send a new key. This is precisely why `retry`\nexists as a field. Never infer recovery from the status."},"retry":{"type":"string","enum":["fix_input","retry_later","do_not_retry"],"description":"What to do about the failure, and the only field you need in order to decide\nwhether to send the request again.\n\n`fix_input` — the request is wrong; change it and retry.\n\n`retry_later` — the request is right, the system is not ready; retry it\nunchanged, with backoff and a FINITE number of attempts. On a 500\n(`internal_error`) this especially does not mean retry indefinitely: the\nfailure is not known to be transient, only that nothing about your request\nneeds changing, so give up after a few attempts rather than looping.\n\n`do_not_retry` — retrying will never help.\n\nFixed per `code`; see the table above. Always present, on every error,\nincluding any code added after this document — so a client that does not\nrecognise a `code` can still act correctly by reading this field alone."},"message":{"type":"string","description":"Human-readable, English, not guaranteed stable wording. Says what to do as well as what went wrong. Never parsed for control flow — that is what `code` and `retry` are for."},"param":{"description":"The request field this error is about, when there is a single identifiable one. Omitted (not `null`) otherwise — including when a validation failure names a field this API cannot extract unambiguously, in which case `message` still names it.","type":"string"},"details":{"description":"Scope denial details for insufficient_scope; retryAfterSeconds, limit and bucket for rate_limited; retryAfterSeconds for data_search_busy.","anyOf":[{"type":"object","properties":{"reason":{"type":"string","enum":["missing_key_scope","owner_permission_limit","invalid_key_scope"]},"requiredScopes":{"type":"array","items":{"type":"string"}},"missingScopes":{"type":"array","items":{"type":"string"}}},"required":["reason","requiredScopes","missingScopes"],"additionalProperties":false},{"type":"object","properties":{"retryAfterSeconds":{"type":"integer","minimum":1,"maximum":9007199254740991},"limit":{"type":"integer","minimum":1,"maximum":9007199254740991},"bucket":{"type":"string","enum":["default","expensive","handshake","transport"]}},"required":["retryAfterSeconds","limit","bucket"],"additionalProperties":false},{"type":"object","properties":{"retryAfterSeconds":{"type":"integer","minimum":1,"maximum":60}},"required":["retryAfterSeconds"],"additionalProperties":false}]},"requestId":{"type":"string","description":"Unique per error response. Not correlated with any other id in this API."}},"required":["type","code","retry","message","requestId"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"ScopeDenialDetailsDto":{"type":"object","properties":{"reason":{"type":"string","enum":["missing_key_scope","owner_permission_limit","invalid_key_scope"]},"requiredScopes":{"type":"array","items":{"type":"string"}},"missingScopes":{"type":"array","items":{"type":"string"}}},"required":["reason","requiredScopes","missingScopes"],"additionalProperties":false},"LookalikeSearchBodyDto":{"type":"object","properties":{"companies":{"minItems":1,"maxItems":50,"type":"array","items":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"description":"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."},"limit":{"default":100,"description":"Maximum number of candidates to return. Fewer may be available; there is no pagination.","type":"integer","minimum":1,"maximum":500},"matchSize":{"default":true,"description":"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.","type":"boolean"},"useWorkspaceAccounts":{"default":true,"description":"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.","type":"boolean"},"minEmployees":{"description":"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.","type":"integer","minimum":1,"maximum":9007199254740991},"maxEmployees":{"description":"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.","type":"integer","minimum":1,"maximum":9007199254740991},"countries":{"description":"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.","minItems":1,"maxItems":250,"type":"array","items":{"type":"string","minLength":2,"maxLength":80}},"regions":{"description":"Hard filter: shorthand for a block of countries (north_america, europe, asia_pacific, latin_america, middle_east, africa). Merged with countries.","minItems":1,"maxItems":6,"type":"array","items":{"type":"string","enum":["north_america","europe","asia_pacific","latin_america","middle_east","africa"]}},"industries":{"description":"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.","minItems":1,"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"includeUnknown":{"default":false,"description":"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.","type":"boolean"}},"required":["companies"],"additionalProperties":false},"LookalikeSearchResponseDto":{"type":"object","properties":{"seeds":{"type":"array","items":{"type":"object","properties":{"input":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"company":{"anyOf":[{"type":"object","properties":{"companyId":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Company dataset ID. Reuse as companyId in a subsequent search."},"name":{"type":"string","description":"The company as Trayo records it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's website domain as Trayo files it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo has it."},"resolvedBy":{"type":"string","enum":["account_match","company_id","linkedin","website","redirect","name"],"description":"Which supplied key identified this company. company_id is an exact lookup."},"nameMatch":{"anyOf":[{"type":"string","enum":["exact","approximate"]},{"type":"null"}],"description":"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."}},"required":["companyId","name","website","linkedinUrl","resolvedBy","nameMatch"],"additionalProperties":false},{"type":"null"}]},"status":{"type":"string","enum":["resolved","not_found","not_indexed"],"description":"resolved: contributed to the search. not_found: no company matched the input. not_indexed: the company matched but similarity data is unavailable for it."}},"required":["input","company","status"],"additionalProperties":false},"description":"One entry per input, in the same order. Check the matched identity, especially for a name-only input."},"companies":{"type":"array","items":{"type":"object","properties":{"companyId":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Company dataset ID. Reuse as companyId in a subsequent search."},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"industry":{"anyOf":[{"type":"string"},{"type":"null"}]},"sizeRange":{"anyOf":[{"type":"string"},{"type":"null"}]},"sizeEmployees":{"anyOf":[{"type":"number"},{"type":"null"}]},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"similarity":{"type":"string","enum":["high","medium","low"],"description":"Heuristic bands: high >= 0.85, medium >= 0.7, low below that. These are preliminary similarity bands, not probabilities."},"similarityScore":{"type":"number","minimum":0,"maximum":1,"description":"Similarity to the closest seed, from 0 to 1. Higher is closer."},"closestSeedIndex":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Zero-based index into seeds for the closest contributing input. Duplicate identities use the first input."},"seedsMatched":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Number of distinct seed companies that retrieved this candidate."}},"required":["companyId","name","website","linkedinUrl","industry","sizeRange","sizeEmployees","country","city","similarity","similarityScore","closestSeedIndex","seedsMatched"],"additionalProperties":false},"description":"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."},"filtered":{"type":"object","properties":{"seedAffiliates":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Candidates left out as a subsidiary, sister brand or another listing of a seed company."},"duplicates":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Candidates left out as another listing of a company already in the results."},"outsideSeedSize":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Candidates left out because their employee count is outside seedSizeRange."},"outsideFilters":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"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":{"anyOf":[{"type":"object","properties":{"minEmployees":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxEmployees":{"type":"integer","minimum":0,"maximum":9007199254740991}},"required":["minEmployees","maxEmployees"],"additionalProperties":false},{"type":"null"}],"description":"The employee range results were kept within. Null when matchSize is false or fewer than three seeds have a known employee count."}},"required":["seedAffiliates","duplicates","outsideSeedSize","outsideFilters","seedSizeRange"],"additionalProperties":false,"description":"What was left out so that only strong matches remain."},"rankedByYourAccounts":{"anyOf":[{"type":"object","properties":{"companies":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"How many of your workspace's account companies were used to rank the results."},"boosted":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Results ranked higher because at least two people at those companies used to work there."}},"required":["companies","boosted"],"additionalProperties":false},{"type":"null"}],"description":"Null when useWorkspaceAccounts is false, your workspace has no matched accounts, or this ranking is unavailable for the request."}},"required":["seeds","companies","filtered","rankedByYourAccounts"],"additionalProperties":false},"CompetitorRosterBodyDto":{"type":"object","properties":{"company":{"anyOf":[{"type":"object","properties":{"self":{"type":"boolean","const":true,"description":"The calling workspace's own company, resolved from its website."}},"required":["self"],"additionalProperties":false},{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false}],"description":"The subject whose competitors you want."},"pins":{"description":"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.","maxItems":5,"type":"array","items":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false}},"cap":{"default":15,"description":"Maximum roster length. Default 15, maximum 50.","type":"integer","minimum":1,"maximum":50},"positioning":{"description":"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.","type":"string","minLength":1,"maxLength":4000},"refresh":{"default":false,"description":"Regenerate even when a stored roster with the same pins is available.","type":"boolean"}},"required":["company"],"additionalProperties":false},"CompetitorRosterResponseDto":{"type":"object","properties":{"subject":{"type":"object","properties":{"companyId":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"name":{"type":"string"},"website":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"resolvedBy":{"type":"string","description":"Which key identified the subject: linkedin, website, redirect, name, or company_id for an exact lookup."},"indexed":{"type":"boolean","description":"false when similarity data is unavailable for the subject; the roster then comes from pins and nominations only."}},"required":["companyId","name","website","linkedinUrl","resolvedBy","indexed"],"additionalProperties":false},"pins":{"type":"array","items":{"type":"object","properties":{"input":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"company":{"anyOf":[{"type":"object","properties":{"companyId":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"name":{"type":"string"},"website":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["companyId","name","website"],"additionalProperties":false},{"type":"null"}]},"status":{"type":"string","enum":["resolved","not_found"]},"seeded":{"type":"boolean","description":"true when this pin seeded the similarity search in the run that produced this response; false on a reused roster."}},"required":["input","company","status","seeded"],"additionalProperties":false},"description":"One entry per stored pin, in rank order."},"entries":{"type":"array","items":{"type":"object","properties":{"rank":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"companyId":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"industry":{"anyOf":[{"type":"string"},{"type":"null"}]},"sizeRange":{"anyOf":[{"type":"string"},{"type":"null"}]},"verdict":{"type":"string","enum":["direct","adjacent","not_competitor"]},"productAlignment":{"type":"integer","minimum":0,"maximum":100},"icpAlignment":{"type":"integer","minimum":0,"maximum":100},"reason":{"type":"string"},"source":{"type":"string","enum":["pool","pin","nomination"],"description":"pool: retrieved from similarity data. pin: you named it. nomination: named while the roster was generated, then matched to a company."},"rankReason":{"type":"string","enum":["pinned","incumbent","alignment"]},"similarity":{"anyOf":[{"type":"number"},{"type":"null"}]},"seedsMatched":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"customers":{"type":"object","properties":{"status":{"type":"string","enum":["not_scanned","pending","running","completed","failed"],"description":"Each roster call starts a background scan when the scan is missing or more than 30 days old. It takes a few minutes; call again to see it fill in. An entry with no website stays not_scanned."},"scrapedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601; when the competitor's site was last read."},"pagesScraped":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"customersFound":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"customersResolved":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"error":{"anyOf":[{"type":"string","enum":["no_pages_fetched","queue_unavailable","scan_failed"]},{"type":"null"}],"description":"Why the last scan failed, or null. no_pages_fetched: none of the competitor's pages could be read. queue_unavailable: the scan could not be started. scan_failed: any other failure. A failed scan is tried again on a roster request made at least 24 hours later."},"customers":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"companyId":{"anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"null"}],"description":"Set when the name or domain matched a company in our data."},"evidenceKind":{"type":"string","enum":["logo","case_study","testimonial","review","customer_list"],"description":"Where on the competitor's site the customer appeared."},"sourceUrl":{"type":"string"},"quote":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The sentence or heading that named the customer, at most 240 characters."},"confidence":{"type":"string","enum":["high","medium","low"]},"flag":{"anyOf":[{"type":"string","enum":["self","tenant"]},{"type":"null"}],"description":"self: the competitor named itself. tenant: it named the company this roster is for."}},"required":["name","domain","companyId","evidenceKind","sourceUrl","quote","confidence","flag"],"additionalProperties":false}}},"required":["status","scrapedAt","pagesScraped","customersFound","customersResolved","error","customers"],"additionalProperties":false,"description":"The customers this competitor shows on its own website."}},"required":["rank","companyId","name","website","linkedinUrl","industry","sizeRange","verdict","productAlignment","icpAlignment","reason","source","rankReason","similarity","seedsMatched","customers"],"additionalProperties":false},"description":"The roster, rank 1 first."},"unresolvedNominations":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"website":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["name","website"],"additionalProperties":false},"description":"Companies named while the roster was generated that matched nothing in our data. Not in the roster."},"cap":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"generatedAt":{"type":"string","description":"ISO 8601."},"reused":{"type":"boolean","description":"true when the stored roster was returned without generating a new one."},"stats":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["subject","pins","entries","unresolvedNominations","cap","generatedAt","reused","stats"],"additionalProperties":false},"AccountListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"url":{"anyOf":[{"type":"string","format":"uri"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"starred":{"type":"boolean"},"scores":{"type":"object","properties":{"timing":{"anyOf":[{"type":"object","properties":{"score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0–100, NORMALIZED: this account's `raw` as a share of the highest `raw` in your workspace, rounded — the definition the product uses for its timing score. 100 is your hottest account right now, which means it moves when OTHER accounts move. Rank on this; threshold on `raw`."},"raw":{"type":"number","description":"The un-normalized score. How much your signals have fired at this account RECENTLY: each matched signal contributes its weight (1–3), decayed with a 30-day half-life, over a 90-day window, stacked and capped at 100. Stable for this account regardless of the rest of the workspace, so an absolute threshold on it holds still."},"calculatedAt":{"type":"string","format":"date-time"}},"required":["score","raw","calculatedAt"],"additionalProperties":false},{"type":"null"}],"description":"Null until Trayo has computed it. `calculatedAt` records when this score was last updated."},"activity":{"anyOf":[{"type":"object","properties":{"total":{"type":"number","description":"Weighted sum of event significance and relevance with recency decay and a diversity bonus, over `lookbackDays`. Scale depends on the workspace's scoring configuration — compare accounts within a workspace, not across workspaces."},"eventCount":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Events that contributed."},"avgSignificance":{"anyOf":[{"type":"number"},{"type":"null"}]},"avgRelevance":{"anyOf":[{"type":"number"},{"type":"null"}]},"recencyBonus":{"type":"number"},"signalDiversityBonus":{"type":"number"},"lookbackDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A generated prose summary of what the contributing events add up to for this account — the \"why\", not just the number. Null until generated; regenerated when new events arrive."},"summaryGeneratedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"calculatedAt":{"type":"string","format":"date-time"}},"required":["total","eventCount","avgSignificance","avgRelevance","recencyBonus","signalDiversityBonus","lookbackDays","summary","summaryGeneratedAt","calculatedAt"],"additionalProperties":false},{"type":"null"}],"description":"Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it."}},"required":["timing","activity"],"additionalProperties":false,"description":"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":{"anyOf":[{"type":"object","properties":{"predictions":{"type":"array","items":{"type":"object","properties":{"point":{"type":"string","description":"One sentence: where the company appears to be heading, and the need that follows from it. Deliberately hedged (\"likely\", \"appears to be\") — it is an outside-in reading of public events, not a confirmed internal plan."},"evidence":{"type":"array","items":{"type":"object","properties":{"eventId":{"type":"string","format":"uuid","description":"A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards."},"eventTitle":{"type":"string"},"eventDate":{"type":"string","format":"date","description":"The date the event happened, `YYYY-MM-DD` — not a timestamp."},"eventSummary":{"anyOf":[{"type":"string"},{"type":"null"}]},"connection":{"type":"string","description":"One line on how THIS event supports the prediction — the causal or strategic link, not a restatement of the title."}},"required":["eventId","eventTitle","eventDate","eventSummary","connection"],"additionalProperties":false},"description":"The events the prediction rests on, at least one and at most three. A prediction with no evidence is never written, so this is never empty."}},"required":["point","evidence"],"additionalProperties":false},"description":"At most three, strongest first. Empty when the events do not support a direction — an honest answer, not a failure."},"whyNow":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal whose recent firing makes the timing matter. Matches a `signalKey` on `GET /v1/signals` and on the cited events."},"point":{"type":"string","description":"One line on why the timing matters now."},"events":{"type":"array","items":{"type":"object","properties":{"eventId":{"type":"string","format":"uuid","description":"A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards."},"eventTitle":{"type":"string"},"eventDate":{"type":"string","format":"date","description":"The date the event happened, `YYYY-MM-DD` — not a timestamp."}},"required":["eventId","eventTitle","eventDate"],"additionalProperties":false},"description":"The last few events that matched this signal, most recent first."}},"required":["signalKey","point","events"],"additionalProperties":false},"description":"At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for."},"generatedAt":{"type":"string","format":"date-time","description":"When this point of view was written."}},"required":["predictions","whyNow","generatedAt"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\nNull 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":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","url","logoUrl","linkedinHandle","starred","scores","pointOfView","createdAt","updatedAt"],"additionalProperties":false},"description":"Accounts, most recently created first."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"CreateAccountDto":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"description":"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":{"description":"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.\n\nOPTIONAL 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.","anyOf":[{"type":"string","format":"uri"},{"type":"null"}]},"linkedinHandle":{"description":"`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.","type":"string","maxLength":200}},"required":["name"],"description":"The created account is automatically assigned to the user your API key acts as, which is what makes it eligible for discovery — see `POST /v1/discoveries`. There is no way to create an unassigned account through this API.\n\n**Unknown fields are rejected** (`validation_failed`), not silently dropped — a misspelled field is an error, so you find out.\n\n**Supply `linkedinHandle` whenever you know the company page.** Use the bare company handle, not the full LinkedIn URL. It is the strongest way to identify the company and is tried before the website and name. Sending it at create time makes a `company_not_found` result less likely.\n\n**`name` is the only required field.** An account you have a name but no website for is created with `url: null` rather than refused, matching `POST /v1/people`'s `company`, and is deduplicated by that name. Send `url` whenever you have it: it is the stronger dedup key (two companies can share a name), and an account with no website cannot be matched to a company for `jobs` and `job_change` discovery by website either.\n\n**A `POST /v1/find` accounts row is NOT postable verbatim.** It carries `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `fundingStage`, `foundedYear`, `companyType`, `description`, `why`, `known`, `state` in addition to `name` and `url`, and every one of those is refused. This API stores a name, a URL and a LinkedIn handle for an account and nothing else; the firmographics on a find row are unverified, reported by the search, and have nowhere to live here. Refusing them is deliberate — accepting and discarding them would have you believe the workspace holds research it does not. Project the result before importing:\n\n```\njq '{accounts: [.data[] | select(.state == \"selectable\" and .url) | {name, url}]}'\n```\n\nIf you need the firmographics, keep them client-side or in your CRM — `GET /v1/accounts` will never return them.","additionalProperties":false},"AccountDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"url":{"anyOf":[{"type":"string","format":"uri"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"starred":{"type":"boolean"},"scores":{"type":"object","properties":{"timing":{"anyOf":[{"type":"object","properties":{"score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0–100, NORMALIZED: this account's `raw` as a share of the highest `raw` in your workspace, rounded — the definition the product uses for its timing score. 100 is your hottest account right now, which means it moves when OTHER accounts move. Rank on this; threshold on `raw`."},"raw":{"type":"number","description":"The un-normalized score. How much your signals have fired at this account RECENTLY: each matched signal contributes its weight (1–3), decayed with a 30-day half-life, over a 90-day window, stacked and capped at 100. Stable for this account regardless of the rest of the workspace, so an absolute threshold on it holds still."},"calculatedAt":{"type":"string","format":"date-time"}},"required":["score","raw","calculatedAt"],"additionalProperties":false},{"type":"null"}],"description":"Null until Trayo has computed it. `calculatedAt` records when this score was last updated."},"activity":{"anyOf":[{"type":"object","properties":{"total":{"type":"number","description":"Weighted sum of event significance and relevance with recency decay and a diversity bonus, over `lookbackDays`. Scale depends on the workspace's scoring configuration — compare accounts within a workspace, not across workspaces."},"eventCount":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Events that contributed."},"avgSignificance":{"anyOf":[{"type":"number"},{"type":"null"}]},"avgRelevance":{"anyOf":[{"type":"number"},{"type":"null"}]},"recencyBonus":{"type":"number"},"signalDiversityBonus":{"type":"number"},"lookbackDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A generated prose summary of what the contributing events add up to for this account — the \"why\", not just the number. Null until generated; regenerated when new events arrive."},"summaryGeneratedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"calculatedAt":{"type":"string","format":"date-time"}},"required":["total","eventCount","avgSignificance","avgRelevance","recencyBonus","signalDiversityBonus","lookbackDays","summary","summaryGeneratedAt","calculatedAt"],"additionalProperties":false},{"type":"null"}],"description":"Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it."}},"required":["timing","activity"],"additionalProperties":false,"description":"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":{"anyOf":[{"type":"object","properties":{"predictions":{"type":"array","items":{"type":"object","properties":{"point":{"type":"string","description":"One sentence: where the company appears to be heading, and the need that follows from it. Deliberately hedged (\"likely\", \"appears to be\") — it is an outside-in reading of public events, not a confirmed internal plan."},"evidence":{"type":"array","items":{"type":"object","properties":{"eventId":{"type":"string","format":"uuid","description":"A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards."},"eventTitle":{"type":"string"},"eventDate":{"type":"string","format":"date","description":"The date the event happened, `YYYY-MM-DD` — not a timestamp."},"eventSummary":{"anyOf":[{"type":"string"},{"type":"null"}]},"connection":{"type":"string","description":"One line on how THIS event supports the prediction — the causal or strategic link, not a restatement of the title."}},"required":["eventId","eventTitle","eventDate","eventSummary","connection"],"additionalProperties":false},"description":"The events the prediction rests on, at least one and at most three. A prediction with no evidence is never written, so this is never empty."}},"required":["point","evidence"],"additionalProperties":false},"description":"At most three, strongest first. Empty when the events do not support a direction — an honest answer, not a failure."},"whyNow":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal whose recent firing makes the timing matter. Matches a `signalKey` on `GET /v1/signals` and on the cited events."},"point":{"type":"string","description":"One line on why the timing matters now."},"events":{"type":"array","items":{"type":"object","properties":{"eventId":{"type":"string","format":"uuid","description":"A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards."},"eventTitle":{"type":"string"},"eventDate":{"type":"string","format":"date","description":"The date the event happened, `YYYY-MM-DD` — not a timestamp."}},"required":["eventId","eventTitle","eventDate"],"additionalProperties":false},"description":"The last few events that matched this signal, most recent first."}},"required":["signalKey","point","events"],"additionalProperties":false},"description":"At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for."},"generatedAt":{"type":"string","format":"date-time","description":"When this point of view was written."}},"required":["predictions","whyNow","generatedAt"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\nNull 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":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","url","logoUrl","linkedinHandle","starred","scores","pointOfView","createdAt","updatedAt"],"additionalProperties":false},"BatchCreateAccountsDto":{"type":"object","properties":{"accounts":{"maxItems":200,"type":"array","items":{},"description":"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.\n\nIndividual rows are validated one at a time: a malformed row is reported in `skipped`, not raised as a request error."}},"required":["accounts"],"additionalProperties":false},"BatchCreateAccountsResultDto":{"type":"object","properties":{"created":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"url":{"anyOf":[{"type":"string","format":"uri"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"starred":{"type":"boolean"},"scores":{"type":"object","properties":{"timing":{"anyOf":[{"type":"object","properties":{"score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0–100, NORMALIZED: this account's `raw` as a share of the highest `raw` in your workspace, rounded — the definition the product uses for its timing score. 100 is your hottest account right now, which means it moves when OTHER accounts move. Rank on this; threshold on `raw`."},"raw":{"type":"number","description":"The un-normalized score. How much your signals have fired at this account RECENTLY: each matched signal contributes its weight (1–3), decayed with a 30-day half-life, over a 90-day window, stacked and capped at 100. Stable for this account regardless of the rest of the workspace, so an absolute threshold on it holds still."},"calculatedAt":{"type":"string","format":"date-time"}},"required":["score","raw","calculatedAt"],"additionalProperties":false},{"type":"null"}],"description":"Null until Trayo has computed it. `calculatedAt` records when this score was last updated."},"activity":{"anyOf":[{"type":"object","properties":{"total":{"type":"number","description":"Weighted sum of event significance and relevance with recency decay and a diversity bonus, over `lookbackDays`. Scale depends on the workspace's scoring configuration — compare accounts within a workspace, not across workspaces."},"eventCount":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Events that contributed."},"avgSignificance":{"anyOf":[{"type":"number"},{"type":"null"}]},"avgRelevance":{"anyOf":[{"type":"number"},{"type":"null"}]},"recencyBonus":{"type":"number"},"signalDiversityBonus":{"type":"number"},"lookbackDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A generated prose summary of what the contributing events add up to for this account — the \"why\", not just the number. Null until generated; regenerated when new events arrive."},"summaryGeneratedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"calculatedAt":{"type":"string","format":"date-time"}},"required":["total","eventCount","avgSignificance","avgRelevance","recencyBonus","signalDiversityBonus","lookbackDays","summary","summaryGeneratedAt","calculatedAt"],"additionalProperties":false},{"type":"null"}],"description":"Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it."}},"required":["timing","activity"],"additionalProperties":false,"description":"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":{"anyOf":[{"type":"object","properties":{"predictions":{"type":"array","items":{"type":"object","properties":{"point":{"type":"string","description":"One sentence: where the company appears to be heading, and the need that follows from it. Deliberately hedged (\"likely\", \"appears to be\") — it is an outside-in reading of public events, not a confirmed internal plan."},"evidence":{"type":"array","items":{"type":"object","properties":{"eventId":{"type":"string","format":"uuid","description":"A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards."},"eventTitle":{"type":"string"},"eventDate":{"type":"string","format":"date","description":"The date the event happened, `YYYY-MM-DD` — not a timestamp."},"eventSummary":{"anyOf":[{"type":"string"},{"type":"null"}]},"connection":{"type":"string","description":"One line on how THIS event supports the prediction — the causal or strategic link, not a restatement of the title."}},"required":["eventId","eventTitle","eventDate","eventSummary","connection"],"additionalProperties":false},"description":"The events the prediction rests on, at least one and at most three. A prediction with no evidence is never written, so this is never empty."}},"required":["point","evidence"],"additionalProperties":false},"description":"At most three, strongest first. Empty when the events do not support a direction — an honest answer, not a failure."},"whyNow":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal whose recent firing makes the timing matter. Matches a `signalKey` on `GET /v1/signals` and on the cited events."},"point":{"type":"string","description":"One line on why the timing matters now."},"events":{"type":"array","items":{"type":"object","properties":{"eventId":{"type":"string","format":"uuid","description":"A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards."},"eventTitle":{"type":"string"},"eventDate":{"type":"string","format":"date","description":"The date the event happened, `YYYY-MM-DD` — not a timestamp."}},"required":["eventId","eventTitle","eventDate"],"additionalProperties":false},"description":"The last few events that matched this signal, most recent first."}},"required":["signalKey","point","events"],"additionalProperties":false},"description":"At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for."},"generatedAt":{"type":"string","format":"date-time","description":"When this point of view was written."}},"required":["predictions","whyNow","generatedAt"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\nNull 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":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"identity":{"type":"string","enum":["resolved","uncertain","not_found","pending"],"description":"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":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","description":"The company id. Send it in `filters.companyIds` on `POST /v1/find` to search this exact company."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"That company, as Trayo names it. Compare it with the name you sent."},"headcount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Its headcount, when known."},"linkedinHandle":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"That company's LinkedIn handle, when Trayo has one. Send it as the account's `linkedinHandle` to pin the identity."}},"required":["id","name","headcount","linkedinHandle"],"additionalProperties":false},{"type":"null"}],"description":"Only for `identity: \"resolved\"`: the company the website belongs to. Null otherwise."},"alternatives":{"anyOf":[{"maxItems":3,"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The company id. Send it in `filters.companyIds` (instead of `filters.companyWebsites`) to search this exact company."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"That company, as Trayo names it."},"headcount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Its headcount, when known."}},"required":["id","name","headcount"],"additionalProperties":false}},{"type":"null"}],"description":"Only for `identity: \"uncertain\"`: the companies this website could name, at most 3. Null otherwise."}},"required":["id","name","url","logoUrl","linkedinHandle","starred","scores","pointOfView","createdAt","updatedAt","identity","matchedCompany","alternatives"],"additionalProperties":false},"description":"The rows that were created, in submission order."},"skipped":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0-based position of the offending row in the submitted array."},"reason":{"type":"string","description":"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":{"description":"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.","type":"string","enum":["duplicate","identity_conflict"]},"existingId":{"description":"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.","type":"string","format":"uuid"}},"required":["index","reason"],"additionalProperties":false},"description":"Rows that were NOT created. Always inspect this: a `200` means the call succeeded, not that every row landed."}},"required":["created","skipped"],"description":"**Partial success is the contract.** A batch of 40 with 3 bad rows creates 37 and reports the other 3 in `skipped` by their position in the submitted array. It never fails the whole request for a subset of bad rows, and the status is always `200` even when every row was skipped. Only a problem with the REQUEST itself — missing auth, the array missing or not an array, or more than 200 items — fails the call.","additionalProperties":false},"UpdateAccountDto":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"url":{"type":"string","format":"uri"},"linkedinHandle":{"type":"string","maxLength":200},"starred":{"type":"boolean"}},"description":"Every field is optional; only fields present in the body are changed. `starred` is the one field settable here but not at creation. A `linkedinHandle` is reduced to the bare company slug before it is stored, so sending the full company URL is safe.","additionalProperties":false},"SignalListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The stable public identifier. There is no separate row id."},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"entityType":{"type":"string"},"detects":{"type":"string"},"salesIntent":{"anyOf":[{"type":"string"},{"type":"null"}]},"includes":{"type":"array","items":{"type":"string"}},"excludes":{"type":"array","items":{"type":"string"}},"weight":{"type":"number"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["signalKey","type","entityType","detects","salesIntent","includes","excludes","weight","createdAt","updatedAt"],"additionalProperties":false},"description":"Every current account-axis signal, newest first."},"hasMore":{"type":"boolean","const":false,"description":"ALWAYS `false`. This route does not paginate."},"nextCursor":{"type":"null","description":"ALWAYS `null`. This route does not paginate."}},"required":["data","hasMore","nextCursor"],"description":"The complete current set of your workspace's account-axis signals in a single response — every signal you have authored.\n\n**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 ignored), and `hasMore` is always `false` with `nextCursor` always `null` however many signals exist. Do not write a pagination loop against it — it will never advance. `POST /v1/find` is the other non-paginating route, and it does not even return this envelope.","additionalProperties":false},"CreateSignalDto":{"type":"object","properties":{"signalKey":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{1,58}[a-z0-9]$","description":"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":{"type":"string","enum":["news","jobs","job_change"],"description":"What this signal searches. `news` searches the open web. `jobs` finds hiring activity in job postings from about the last 60 days only: a longer lookback does not reach older postings, so report a jobs signal's period as about 60 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.\n\n`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":{"type":"string","maxLength":2000,"description":"Describe what counts as a match in plain language."},"salesIntent":{"description":"Why a match matters commercially.","type":"string","maxLength":2000},"includes":{"description":"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.","maxItems":50,"type":"array","items":{"type":"string"}},"excludes":{"description":"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.","maxItems":50,"type":"array","items":{"type":"string"}},"weight":{"description":"Relative importance, 1-3. Default 1.","type":"integer","minimum":1,"maximum":3}},"required":["signalKey","type","detects"],"description":"A new signal is usable immediately: send its key to `POST /v1/discoveries` as soon as this call answers. No further call is needed before starting discovery.","additionalProperties":false},"SignalDto":{"type":"object","properties":{"signalKey":{"type":"string","description":"The stable public identifier. There is no separate row id."},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"entityType":{"type":"string"},"detects":{"type":"string"},"salesIntent":{"anyOf":[{"type":"string"},{"type":"null"}]},"includes":{"type":"array","items":{"type":"string"}},"excludes":{"type":"array","items":{"type":"string"}},"weight":{"type":"number"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["signalKey","type","entityType","detects","salesIntent","includes","excludes","weight","createdAt","updatedAt"],"additionalProperties":false},"UpdateSignalDto":{"type":"object","properties":{"detects":{"type":"string","maxLength":2000},"salesIntent":{"type":"string","maxLength":2000},"includes":{"description":"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.","maxItems":50,"type":"array","items":{"type":"string"}},"excludes":{"description":"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.","maxItems":50,"type":"array","items":{"type":"string"}},"weight":{"type":"integer","minimum":1,"maximum":3}},"description":"Every field is optional; only fields present are changed. `signalKey` and `type` are absent on purpose — the key is the resource's identity and sending either is a `validation_failed`.\n\nAn edit takes effect on the next run that names the signal.","additionalProperties":false},"SignalDeleteResultDto":{"type":"object","properties":{"deleted":{"type":"boolean","const":true},"signalKey":{"type":"string"}},"required":["deleted","signalKey"],"description":"Confirmation, NOT the deleted signal — the response shape differs from every other delete in this API. The `signalKey` becomes immediately reusable in a new `POST /v1/signals` call.","additionalProperties":false},"StartDiscoveryDto":{"type":"object","properties":{"accountIds":{"minItems":1,"maxItems":500,"type":"array","items":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"description":"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":{"type":"integer","minimum":1,"maximum":365,"description":"How far back to look for events, in days. `jobs` signals read job postings from about the last 60 days only, whatever this value is: report their period as about 60 days, not the lookback."},"maxResults":{"description":"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.","type":"integer","minimum":1,"maximum":1000},"signalKeys":{"description":"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.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string"}},"people":{"default":"none","description":"`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.","type":"string","enum":["add","none"]}},"required":["accountIds","lookbackDays"],"additionalProperties":false},"DiscoveryRunDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["running","completed","failed"],"description":"`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.\n\n**`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.\n\n**`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":{"type":"object","properties":{"kind":{"type":"string","const":"accounts"},"accountIds":{"type":"array","items":{"type":"string"}},"signalKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false,"description":"The accounts and signals requested for this run. Older runs can omit these details."},"lookbackDays":{"type":"number"},"maxResults":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"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":{"type":"string","enum":["add","none"],"description":"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":{"type":"number","description":"How many accounts the run was asked to scan."},"relatedEvidence":{"description":"Optional credential-owned indexed evidence about a related implementation intent. Its candidates never change this run's original events, counts or settlement.","type":"object","properties":{"profile":{"type":"string","enum":["ap_p2p_implementation_v1","ap_p2p_related_jobs_v2"]},"relation":{"type":"string","enum":["related_candidate","alternative_candidate"],"description":"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":{"type":"array","items":{"type":"string"}},"families":{"description":"v2 selected families and original signal keys; item attribution is phrase-based only.","type":"array","items":{"type":"object","properties":{"family":{"type":"string","enum":["ap_implementation","ap_outsourcing"]},"signalKeys":{"minItems":1,"type":"array","items":{"type":"string"}}},"required":["family","signalKeys"],"additionalProperties":false}},"status":{"type":"string","enum":["running","done","unavailable"]},"searchId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The existing evidence search, readable only by the credential that started this run. Reading it does not repeat the search."},"searchedAccountIds":{"type":"array","items":{"type":"string"},"description":"Accounts submitted to this pass; native source coverage says what was actually read."},"notSearchedAccountIds":{"type":"array","items":{"type":"string"},"description":"Requested run accounts excluded by eligibility or the existing evidence-search company bound."},"sources":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"status":{"type":"string","enum":["running","done","partial","failed"],"description":"`running`: not finished yet (job postings may already show a first read while it reads cut phrases again). `done`: finished. `partial`: finished with what it could read (see `coverage.note`). `failed`: nothing from this source; `error` says why and whether to retry. A failed source is never \"no matches\"."},"window":{"anyOf":[{"type":"object","properties":{"from":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The earliest date searched, YYYY-MM-DD (import records: the date before which a record is stale)."},"to":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The latest date searched, YYYY-MM-DD."},"heldFrom":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The oldest date this source holds, when it holds less than you asked for."},"requestedDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The `withinDays` you sent."},"limitedByData":{"type":"boolean","description":"True when the source holds less than `withinDays` back: report `from` to `to`, not the window you asked for."}},"required":["from","to","heldFrom","requestedDays","limitedByData"],"additionalProperties":false},{"type":"null"}],"description":"The dates this source covered; null until its first read answers."},"matches":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Items this source returned."},"totalMatches":{"anyOf":[{"type":"integer","minimum":0,"maximum":9007199254740991},{"type":"null"}],"description":"Matches the source counted among what it read, when it counts them (SEC: documents; job postings: jobs among the postings read, each job once however often it is listed). Not a count of the whole market: report it as \"at least\"."},"coverage":{"anyOf":[{"type":"object","properties":{"complete":{"type":"boolean","description":"False when the source read only part of what matches (a cap or a time budget): narrow the search rather than report a total."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What limited this source and how to narrow it."},"cutPhrases":{"description":"Job postings: the phrases a cap still cut, after the search read each cut phrase again on its own while time allowed; every other phrase was searched in full. Send each again as `note` says: more distinctive words or narrower `titles`, or a search of its own.","type":"array","items":{"type":"string"}},"phrases":{"description":"Job postings with `phrases`: each phrase's coverage, from its last read.","type":"array","items":{"type":"object","properties":{"phrase":{"type":"string"},"complete":{"type":"boolean","description":"Every posting holding its words was read and checked."},"searchedAlone":{"type":"boolean","description":"A cap cut it in the shared read, so it was read again on its own."},"read":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Postings read that hold all of its words (with `titles`, a title too)."},"checked":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of those, the ones decided: checked for the exact phrase, or a one-word phrase's index match."},"matched":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of those, the postings that matched."}},"required":["phrase","complete","searchedAlone","read","checked","matched"],"additionalProperties":false}},"cutCompanies":{"description":"Job postings with `companies`: the companies whose own postings a cap cut; every other one was read in full. Send each again as `note` says: in a search with fewer companies, or with narrower `titles`.","type":"array","items":{"type":"string"}}},"required":["complete","note"],"additionalProperties":false},{"type":"null"}]},"unresolvedCompanies":{"type":"array","items":{"type":"string"},"description":"`companies` this source could not match (SEC filings: names that match no filer; job postings: no company of that name)."},"notSearched":{"type":"array","items":{"type":"string"},"description":"Requested terms or companies not searched; partial coverage, never evidence of no matches."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Send back as `cursor` with this one source and the same fields for more. Null at the end."},"error":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"The error code, as any Trayo error carries it."},"retry":{"type":"string","description":"`retry_later`, `fix_input` or `do_not_retry`."},"message":{"type":"string"},"retryAfterSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"How long to wait before retrying, when the source said; null otherwise."}},"required":["code","retry","message","retryAfterSeconds"],"additionalProperties":false},{"type":"null"}]},"rollup":{"anyOf":[{"type":"object","properties":{"companies":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The company as the source names it: the plaintiff, the filer, the importer, the employer."},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your workspace account for it; null when it is not one of yours."},"matches":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Its matches among those counted: cases, filings, import records or jobs."},"listings":{"description":"Job postings only: the listings of those jobs (a job listed in several places is one match).","type":"integer","minimum":0,"maximum":9007199254740991},"newestDate":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD of its newest match."}},"required":["name","accountId","matches","newestDate"],"additionalProperties":false},"description":"At most 20, most matches first, then the newest."},"counted":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"The matches counted for this ranking."},"complete":{"type":"boolean","description":"True when every match was counted; false when a cap or the time stopped the count (see `note`)."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What the count covers and how to complete it."}},"required":["companies","counted","complete","note"],"additionalProperties":false},{"type":"null"}],"description":"Null for this named-account pass."}},"required":["source","status","window","matches","totalMatches","coverage","unresolvedCompanies","notSearched","nextCursor","error","rollup"],"additionalProperties":false}},"items":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"company":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company as the source names it (employer, filer, party, importer)."},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your workspace account: one you named in `companies`, or an account with the same domain or the same name."}},"required":["name","domain","accountId"],"additionalProperties":false},"date":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD board/stored date (details.dateBasis), not proof of the opening/repost date."},"title":{"type":"string","description":"The posting title."},"excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Verbatim match with listed place in brackets; about 400 characters, or null."},"matchedPhrases":{"type":"array","items":{"type":"string"},"description":"Title and description terms actually matched."},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The original posting URL."},"details":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"type":"string"}}]},"description":"Job provenance: postingId; city/region/country, remote (listed place); listings (one job across cities); active (any listing active, false closed/removed, null unknown), statusBasis=stored_listing_status, not live-verified. boardPostedAt; dateBasis=board_posted or stored_record_date; firstSeen/lastSeen=min/max stored dates, not crawl/observation/status checks. postedBeforeWindow; companyId; matchedTitle; matchingPostings. An active group need not make its cited listing open; inspect duties."},"candidateFamilies":{"description":"v2: selected description-phrase matches, not verified outsourcing/implementation or original hits.","type":"array","items":{"type":"string","enum":["ap_implementation","ap_outsourcing"]}}},"required":["source","company","date","title","excerpt","matchedPhrases","url","details"],"additionalProperties":false}},"note":{"type":"string","description":"Scope and candidate limitations. A posting date, stored active status or prior experience does not establish an ongoing project or an RFP."}},"required":["profile","relation","signalKeys","status","searchId","searchedAccountIds","notSearchedAccountIds","sources","items","note"],"additionalProperties":false},"candidatesScreened":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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`.\n\n**Final once `settledAt` is set**, and still moving before that.\n\n**`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":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal."},"candidatesScreened":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Candidates this signal retrieved and screened in this run. Not events."},"candidatesMatched":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many of those matched this signal and went into an event, new or already in your workspace. 0 means the signal matched nothing in this run. Not a count of accounts: several matched candidates can be one event, and several events one account."},"accountsWithEvents":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"How many distinct accounts have at least one event from this signal in this run: the numerator of the signal's fire rate, over the run's `accountCount`. Use this, not `candidatesMatched`, for a fire rate or a share of accounts. Final once `settledAt` is set. Null only on a run settled before this field existed."}},"required":["signalKey","candidatesScreened","candidatesMatched","accountsWithEvents"],"additionalProperties":false,"description":"How many candidates one signal screened in this run, how many of them matched it, and at how many accounts."}},{"type":"null"}],"description":"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":{"type":"number","deprecated":true,"description":"**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":{"type":"number","description":"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.\n\nThe 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.\n\n**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.\n\n**`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":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal that produced nothing here, and could not have."},"reason":{"type":"string","enum":["account_not_ready","company_not_found"],"description":"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":{"type":"array","items":{"type":"string","format":"uuid"},"description":"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":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many of the accounts in this run the signal cannot search for this reason. Never truncated, unlike `accountIds`."},"accountsRequested":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"string","description":"The same thing in prose, including what to do next."}},"required":["signalKey","reason","accountIds","accountsAffected","accountsRequested","message"],"additionalProperties":false,"description":"A signal that cannot produce results for part of this run, and why."},"description":"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":{"type":"boolean","description":"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":{"type":"object","properties":{"count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Account × signal checks that did not finish. A run over 50 accounts and 10 signals makes 500 checks."},"bySignal":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal whose search did not finish for these accounts."},"reason":{"type":"string","enum":["timeout","source_unavailable","internal"],"description":"`timeout`: a data source took too long to answer. `source_unavailable`: a data source refused or dropped the request. `internal`: anything else. The first two are transient; re-running the same accounts and signal later is the fix."},"failedAccounts":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many accounts this signal did not finish searching for this reason. Never truncated."},"accountIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Which accounts, so a re-run can name exactly those. Truncated to the first 200; `failedAccounts` is always the true total."}},"required":["signalKey","reason","failedAccounts","accountIds"],"additionalProperties":false,"description":"One signal that did not finish searching some accounts, and why."},"description":"The failed checks grouped by signal and reason. Empty on a clean run."}},"required":["count","bySignal"],"additionalProperties":false,"description":"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":{"type":"object","properties":{"accountsFinished":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Accounts the run was asked to scan."},"etaSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"description":"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.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"retryAfterSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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."}},"required":["accountsFinished","accountsTotal","etaSeconds","estimatedSettleAt","retryAfterSeconds"],"additionalProperties":false,"description":"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":{"anyOf":[{"type":"string","enum":["retry_later"]},{"type":"null"}],"description":"`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":{"type":"string","format":"date-time"},"completedAt":{"description":"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.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"settledAt":{"description":"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.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["id","status","target","lookbackDays","maxResults","people","accountCount","candidatesScreened","candidatesMatched","bySignal","eventsFound","eventsNew","blockedSignals","partial","failedChecks","progress","retry","startedAt","completedAt","settledAt","error"],"description":"A discovery run. Discovery is ASYNCHRONOUS: `POST /v1/discoveries` returns 202 immediately, then you poll `GET /v1/discoveries/{runId}` until `settledAt` is non-null — the point at which `status` reads `completed` or `failed` and the counts stop moving — then read the scoped events, `error`, `blockedSignals`, and `partial`/`failedChecks`.","additionalProperties":false},"DiscoveryRunListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["running","completed","failed"],"description":"`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.\n\n**`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.\n\n**`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":{"type":"object","properties":{"kind":{"type":"string","const":"accounts"},"accountIds":{"type":"array","items":{"type":"string"}},"signalKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false,"description":"The accounts and signals requested for this run. Older runs can omit these details."},"lookbackDays":{"type":"number"},"maxResults":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"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":{"type":"string","enum":["add","none"],"description":"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":{"type":"number","description":"How many accounts the run was asked to scan."},"relatedEvidence":{"description":"Optional credential-owned indexed evidence about a related implementation intent. Its candidates never change this run's original events, counts or settlement.","type":"object","properties":{"profile":{"type":"string","enum":["ap_p2p_implementation_v1","ap_p2p_related_jobs_v2"]},"relation":{"type":"string","enum":["related_candidate","alternative_candidate"],"description":"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":{"type":"array","items":{"type":"string"}},"families":{"description":"v2 selected families and original signal keys; item attribution is phrase-based only.","type":"array","items":{"type":"object","properties":{"family":{"type":"string","enum":["ap_implementation","ap_outsourcing"]},"signalKeys":{"minItems":1,"type":"array","items":{"type":"string"}}},"required":["family","signalKeys"],"additionalProperties":false}},"status":{"type":"string","enum":["running","done","unavailable"]},"searchId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The existing evidence search, readable only by the credential that started this run. Reading it does not repeat the search."},"searchedAccountIds":{"type":"array","items":{"type":"string"},"description":"Accounts submitted to this pass; native source coverage says what was actually read."},"notSearchedAccountIds":{"type":"array","items":{"type":"string"},"description":"Requested run accounts excluded by eligibility or the existing evidence-search company bound."},"sources":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"status":{"type":"string","enum":["running","done","partial","failed"],"description":"`running`: not finished yet (job postings may already show a first read while it reads cut phrases again). `done`: finished. `partial`: finished with what it could read (see `coverage.note`). `failed`: nothing from this source; `error` says why and whether to retry. A failed source is never \"no matches\"."},"window":{"anyOf":[{"type":"object","properties":{"from":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The earliest date searched, YYYY-MM-DD (import records: the date before which a record is stale)."},"to":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The latest date searched, YYYY-MM-DD."},"heldFrom":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The oldest date this source holds, when it holds less than you asked for."},"requestedDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The `withinDays` you sent."},"limitedByData":{"type":"boolean","description":"True when the source holds less than `withinDays` back: report `from` to `to`, not the window you asked for."}},"required":["from","to","heldFrom","requestedDays","limitedByData"],"additionalProperties":false},{"type":"null"}],"description":"The dates this source covered; null until its first read answers."},"matches":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Items this source returned."},"totalMatches":{"anyOf":[{"type":"integer","minimum":0,"maximum":9007199254740991},{"type":"null"}],"description":"Matches the source counted among what it read, when it counts them (SEC: documents; job postings: jobs among the postings read, each job once however often it is listed). Not a count of the whole market: report it as \"at least\"."},"coverage":{"anyOf":[{"type":"object","properties":{"complete":{"type":"boolean","description":"False when the source read only part of what matches (a cap or a time budget): narrow the search rather than report a total."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What limited this source and how to narrow it."},"cutPhrases":{"description":"Job postings: the phrases a cap still cut, after the search read each cut phrase again on its own while time allowed; every other phrase was searched in full. Send each again as `note` says: more distinctive words or narrower `titles`, or a search of its own.","type":"array","items":{"type":"string"}},"phrases":{"description":"Job postings with `phrases`: each phrase's coverage, from its last read.","type":"array","items":{"type":"object","properties":{"phrase":{"type":"string"},"complete":{"type":"boolean","description":"Every posting holding its words was read and checked."},"searchedAlone":{"type":"boolean","description":"A cap cut it in the shared read, so it was read again on its own."},"read":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Postings read that hold all of its words (with `titles`, a title too)."},"checked":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of those, the ones decided: checked for the exact phrase, or a one-word phrase's index match."},"matched":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of those, the postings that matched."}},"required":["phrase","complete","searchedAlone","read","checked","matched"],"additionalProperties":false}},"cutCompanies":{"description":"Job postings with `companies`: the companies whose own postings a cap cut; every other one was read in full. Send each again as `note` says: in a search with fewer companies, or with narrower `titles`.","type":"array","items":{"type":"string"}}},"required":["complete","note"],"additionalProperties":false},{"type":"null"}]},"unresolvedCompanies":{"type":"array","items":{"type":"string"},"description":"`companies` this source could not match (SEC filings: names that match no filer; job postings: no company of that name)."},"notSearched":{"type":"array","items":{"type":"string"},"description":"Requested terms or companies not searched; partial coverage, never evidence of no matches."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Send back as `cursor` with this one source and the same fields for more. Null at the end."},"error":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"The error code, as any Trayo error carries it."},"retry":{"type":"string","description":"`retry_later`, `fix_input` or `do_not_retry`."},"message":{"type":"string"},"retryAfterSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"How long to wait before retrying, when the source said; null otherwise."}},"required":["code","retry","message","retryAfterSeconds"],"additionalProperties":false},{"type":"null"}]},"rollup":{"anyOf":[{"type":"object","properties":{"companies":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The company as the source names it: the plaintiff, the filer, the importer, the employer."},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your workspace account for it; null when it is not one of yours."},"matches":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Its matches among those counted: cases, filings, import records or jobs."},"listings":{"description":"Job postings only: the listings of those jobs (a job listed in several places is one match).","type":"integer","minimum":0,"maximum":9007199254740991},"newestDate":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD of its newest match."}},"required":["name","accountId","matches","newestDate"],"additionalProperties":false},"description":"At most 20, most matches first, then the newest."},"counted":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"The matches counted for this ranking."},"complete":{"type":"boolean","description":"True when every match was counted; false when a cap or the time stopped the count (see `note`)."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What the count covers and how to complete it."}},"required":["companies","counted","complete","note"],"additionalProperties":false},{"type":"null"}],"description":"Null for this named-account pass."}},"required":["source","status","window","matches","totalMatches","coverage","unresolvedCompanies","notSearched","nextCursor","error","rollup"],"additionalProperties":false}},"items":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"company":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company as the source names it (employer, filer, party, importer)."},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your workspace account: one you named in `companies`, or an account with the same domain or the same name."}},"required":["name","domain","accountId"],"additionalProperties":false},"date":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD board/stored date (details.dateBasis), not proof of the opening/repost date."},"title":{"type":"string","description":"The posting title."},"excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Verbatim match with listed place in brackets; about 400 characters, or null."},"matchedPhrases":{"type":"array","items":{"type":"string"},"description":"Title and description terms actually matched."},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The original posting URL."},"details":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"type":"string"}}]},"description":"Job provenance: postingId; city/region/country, remote (listed place); listings (one job across cities); active (any listing active, false closed/removed, null unknown), statusBasis=stored_listing_status, not live-verified. boardPostedAt; dateBasis=board_posted or stored_record_date; firstSeen/lastSeen=min/max stored dates, not crawl/observation/status checks. postedBeforeWindow; companyId; matchedTitle; matchingPostings. An active group need not make its cited listing open; inspect duties."},"candidateFamilies":{"description":"v2: selected description-phrase matches, not verified outsourcing/implementation or original hits.","type":"array","items":{"type":"string","enum":["ap_implementation","ap_outsourcing"]}}},"required":["source","company","date","title","excerpt","matchedPhrases","url","details"],"additionalProperties":false}},"note":{"type":"string","description":"Scope and candidate limitations. A posting date, stored active status or prior experience does not establish an ongoing project or an RFP."}},"required":["profile","relation","signalKeys","status","searchId","searchedAccountIds","notSearchedAccountIds","sources","items","note"],"additionalProperties":false},"candidatesScreened":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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`.\n\n**Final once `settledAt` is set**, and still moving before that.\n\n**`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":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal."},"candidatesScreened":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Candidates this signal retrieved and screened in this run. Not events."},"candidatesMatched":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many of those matched this signal and went into an event, new or already in your workspace. 0 means the signal matched nothing in this run. Not a count of accounts: several matched candidates can be one event, and several events one account."},"accountsWithEvents":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"How many distinct accounts have at least one event from this signal in this run: the numerator of the signal's fire rate, over the run's `accountCount`. Use this, not `candidatesMatched`, for a fire rate or a share of accounts. Final once `settledAt` is set. Null only on a run settled before this field existed."}},"required":["signalKey","candidatesScreened","candidatesMatched","accountsWithEvents"],"additionalProperties":false,"description":"How many candidates one signal screened in this run, how many of them matched it, and at how many accounts."}},{"type":"null"}],"description":"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":{"type":"number","deprecated":true,"description":"**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":{"type":"number","description":"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.\n\nThe 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.\n\n**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.\n\n**`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":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal that produced nothing here, and could not have."},"reason":{"type":"string","enum":["account_not_ready","company_not_found"],"description":"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":{"type":"array","items":{"type":"string","format":"uuid"},"description":"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":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many of the accounts in this run the signal cannot search for this reason. Never truncated, unlike `accountIds`."},"accountsRequested":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"string","description":"The same thing in prose, including what to do next."}},"required":["signalKey","reason","accountIds","accountsAffected","accountsRequested","message"],"additionalProperties":false,"description":"A signal that cannot produce results for part of this run, and why."},"description":"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":{"type":"boolean","description":"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":{"type":"object","properties":{"count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Account × signal checks that did not finish. A run over 50 accounts and 10 signals makes 500 checks."},"bySignal":{"type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string","description":"The signal whose search did not finish for these accounts."},"reason":{"type":"string","enum":["timeout","source_unavailable","internal"],"description":"`timeout`: a data source took too long to answer. `source_unavailable`: a data source refused or dropped the request. `internal`: anything else. The first two are transient; re-running the same accounts and signal later is the fix."},"failedAccounts":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many accounts this signal did not finish searching for this reason. Never truncated."},"accountIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Which accounts, so a re-run can name exactly those. Truncated to the first 200; `failedAccounts` is always the true total."}},"required":["signalKey","reason","failedAccounts","accountIds"],"additionalProperties":false,"description":"One signal that did not finish searching some accounts, and why."},"description":"The failed checks grouped by signal and reason. Empty on a clean run."}},"required":["count","bySignal"],"additionalProperties":false,"description":"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":{"type":"object","properties":{"accountsFinished":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Accounts the run was asked to scan."},"etaSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"description":"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.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"retryAfterSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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."}},"required":["accountsFinished","accountsTotal","etaSeconds","estimatedSettleAt","retryAfterSeconds"],"additionalProperties":false,"description":"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":{"anyOf":[{"type":"string","enum":["retry_later"]},{"type":"null"}],"description":"`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":{"type":"string","format":"date-time"},"completedAt":{"description":"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.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"settledAt":{"description":"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.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["id","status","target","lookbackDays","maxResults","people","accountCount","candidatesScreened","candidatesMatched","bySignal","eventsFound","eventsNew","blockedSignals","partial","failedChecks","progress","retry","startedAt","completedAt","settledAt","error"],"additionalProperties":false,"description":"A discovery run. Discovery is ASYNCHRONOUS: `POST /v1/discoveries` returns 202 immediately, then you poll `GET /v1/discoveries/{runId}` until `settledAt` is non-null — the point at which `status` reads `completed` or `failed` and the counts stop moving — then read the scoped events, `error`, `blockedSignals`, and `partial`/`failedChecks`."},"description":"Discovery runs, most recently started first."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"EventListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"type":"string","format":"uuid"},"accountName":{"type":"string","description":"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":{"anyOf":[{"type":"string","format":"uri"},{"type":"null"}],"description":"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":{"type":"string"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourceObservation":{"description":"Source summary with its quotations, acquired headline, or null when no retained receipt exists; separate from seller relevance.","anyOf":[{"type":"object","properties":{"basis":{"type":"string","enum":["source_quotes","source_headline","court_docket"]},"text":{"type":"string","minLength":1,"maxLength":1200},"quotes":{"maxItems":3,"type":"array","items":{"type":"string","minLength":1,"maxLength":1200}},"scopeNote":{"type":"string","minLength":1,"maxLength":500}},"required":["basis","text","quotes","scopeNote"],"additionalProperties":false},{"type":"null"}]},"whyItMatters":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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:\".\n\n`null` when discovery did not produce one — older events, and events whose signal type skips that stage. Never an empty string."},"offerings":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The name your workspace gave the offering."},"description":{"type":"string","description":"Why that offering is relevant to this event, in one line."},"link":{"type":"string","description":"Where to read more about the offering. `\"\"` when none was set."}},"required":["name","description","link"],"additionalProperties":false}},{"type":"null"}],"description":"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`.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}]},"publication":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventDate":{"type":"string","format":"date-time"},"signalType":{"type":"string"},"signalKeys":{"type":"array","items":{"type":"string"},"description":"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.\n\n**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.\n\nUsually 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.\n\n`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":{"anyOf":[{"type":"object","properties":{"type":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"How the person moved — for example `new_hire` or `promotion`."},"date":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"When the move happened, as discovery read it off the profile."},"previousRole":{"anyOf":[{"type":"object","properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}]},"since":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["title","company","since"],"additionalProperties":false},{"type":"null"}],"description":"Where they came from. `null` when discovery found no prior role."}},"required":["type","date","previousRole"],"additionalProperties":false},{"type":"null"}],"description":"The move behind a `job_change` event — the same object webhook payloads carry as `jobChange`.\n\n`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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The state, province or similar region."},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"remote":{"type":"boolean","description":"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."}},"required":["city","region","country","remote"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\nA 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":{"anyOf":[{"type":"object","properties":{"listings":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"array","items":{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The state, province or similar region."},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"remote":{"type":"boolean","description":"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."}},"required":["city","region","country","remote"],"additionalProperties":false},"description":"Every place those listings name, each once, `location` first. `[]` when none records a place."},"active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The day the first listing was recorded, YYYY-MM-DD. `null` when not recorded."},"lastSeen":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The day the latest listing was recorded, YYYY-MM-DD. `null` when not recorded."}},"required":["listings","locations","active","firstSeen","lastSeen"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\n`null` on every event whose `signalType` is not `jobs`, and on jobs events discovery wrote before it grouped listings (2026-10)."},"state":{"type":"string","enum":["live","dismissed"],"description":"The only two states this API publishes. An event that has not reached one of them is not addressable yet — it 404s.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nOnly ever set on an event whose `state` is `dismissed`, and only when a reason was recorded. `null` on every `live` event."},"createdAt":{"type":"string","format":"date-time"},"people":{"description":"Present only when `expand` was sent on the list route. Always present on `GET /v1/events/{id}`.\n\n**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.\n\n(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.\n\nThe 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.","type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"string","description":"Why this person is relevant to this event."}},"required":["id","fullName","title","linkedinUrl","photoUrl","reasoning"],"additionalProperties":false}},"signals":{"description":"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.\n\nOne 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.","type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string"},"type":{"type":"string"},"confidence":{"anyOf":[{"type":"string","enum":["low","medium","high"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nFor jobs and job changes, both fields instead restate the retained posting or career facts, or are null when none remain.\n\n`null` when the run recorded no reasoning for this signal. Never an empty string."}},"required":["signalKey","type","confidence","evidence"],"additionalProperties":false}}},"required":["id","accountId","accountName","accountUrl","title","summary","whyItMatters","offerings","url","publication","eventDate","signalType","signalKeys","jobChange","location","jobPosting","state","dismissalReason","createdAt"],"additionalProperties":false},"description":"Events, most recently created first."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"EventDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"type":"string","format":"uuid"},"accountName":{"type":"string","description":"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":{"anyOf":[{"type":"string","format":"uri"},{"type":"null"}],"description":"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":{"type":"string"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourceObservation":{"description":"Source summary with its quotations, acquired headline, or null when no retained receipt exists; separate from seller relevance.","anyOf":[{"type":"object","properties":{"basis":{"type":"string","enum":["source_quotes","source_headline","court_docket"]},"text":{"type":"string","minLength":1,"maxLength":1200},"quotes":{"maxItems":3,"type":"array","items":{"type":"string","minLength":1,"maxLength":1200}},"scopeNote":{"type":"string","minLength":1,"maxLength":500}},"required":["basis","text","quotes","scopeNote"],"additionalProperties":false},{"type":"null"}]},"whyItMatters":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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:\".\n\n`null` when discovery did not produce one — older events, and events whose signal type skips that stage. Never an empty string."},"offerings":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The name your workspace gave the offering."},"description":{"type":"string","description":"Why that offering is relevant to this event, in one line."},"link":{"type":"string","description":"Where to read more about the offering. `\"\"` when none was set."}},"required":["name","description","link"],"additionalProperties":false}},{"type":"null"}],"description":"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`.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}]},"publication":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventDate":{"type":"string","format":"date-time"},"signalType":{"type":"string"},"signalKeys":{"type":"array","items":{"type":"string"},"description":"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.\n\n**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.\n\nUsually 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.\n\n`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":{"anyOf":[{"type":"object","properties":{"type":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"How the person moved — for example `new_hire` or `promotion`."},"date":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"When the move happened, as discovery read it off the profile."},"previousRole":{"anyOf":[{"type":"object","properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}]},"since":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["title","company","since"],"additionalProperties":false},{"type":"null"}],"description":"Where they came from. `null` when discovery found no prior role."}},"required":["type","date","previousRole"],"additionalProperties":false},{"type":"null"}],"description":"The move behind a `job_change` event — the same object webhook payloads carry as `jobChange`.\n\n`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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The state, province or similar region."},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"remote":{"type":"boolean","description":"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."}},"required":["city","region","country","remote"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\nA 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":{"anyOf":[{"type":"object","properties":{"listings":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"array","items":{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The state, province or similar region."},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"remote":{"type":"boolean","description":"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."}},"required":["city","region","country","remote"],"additionalProperties":false},"description":"Every place those listings name, each once, `location` first. `[]` when none records a place."},"active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The day the first listing was recorded, YYYY-MM-DD. `null` when not recorded."},"lastSeen":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The day the latest listing was recorded, YYYY-MM-DD. `null` when not recorded."}},"required":["listings","locations","active","firstSeen","lastSeen"],"additionalProperties":false},{"type":"null"}],"description":"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.\n\n`null` on every event whose `signalType` is not `jobs`, and on jobs events discovery wrote before it grouped listings (2026-10)."},"state":{"type":"string","enum":["live","dismissed"],"description":"The only two states this API publishes. An event that has not reached one of them is not addressable yet — it 404s.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nOnly ever set on an event whose `state` is `dismissed`, and only when a reason was recorded. `null` on every `live` event."},"createdAt":{"type":"string","format":"date-time"},"people":{"description":"Present only when `expand` was sent on the list route. Always present on `GET /v1/events/{id}`.\n\n**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.\n\n(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.\n\nThe 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.","type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"string","description":"Why this person is relevant to this event."}},"required":["id","fullName","title","linkedinUrl","photoUrl","reasoning"],"additionalProperties":false}},"signals":{"description":"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.\n\nOne 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.","type":"array","items":{"type":"object","properties":{"signalKey":{"type":"string"},"type":{"type":"string"},"confidence":{"anyOf":[{"type":"string","enum":["low","medium","high"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nFor jobs and job changes, both fields instead restate the retained posting or career facts, or are null when none remain.\n\n`null` when the run recorded no reasoning for this signal. Never an empty string."}},"required":["signalKey","type","confidence","evidence"],"additionalProperties":false}}},"required":["id","accountId","accountName","accountUrl","title","summary","whyItMatters","offerings","url","publication","eventDate","signalType","signalKeys","jobChange","location","jobPosting","state","dismissalReason","createdAt"],"additionalProperties":false},"PersonListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"uuid"},"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\n**Sourcing, confidence, dates and \"unverified\" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo.\n\nOmit 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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nYours, 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.\n\n**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.\n\nUp to 1000 characters; longer is `validation_failed`, not a truncation.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written by a caller or in the app; `enrichment` = found by a lookup."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"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.\n\nA 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.\n\nWhat 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.\n\n`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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"stakeholderReport":{"description":"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.\n\n`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.\n\nPresent 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`.","anyOf":[{"type":"string"},{"type":"null"}]},"events":{"description":"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.\n\nOnly 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.\n\nAt 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`.\n\n`[]` 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).\n\nPresent on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent.","type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Read the full event with `GET /v1/events/{id}`."},"title":{"type":"string"},"eventDate":{"type":"string","format":"date-time"},"signalType":{"type":"string"},"state":{"type":"string","enum":["live","dismissed"],"description":"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":{"type":"string","description":"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."}},"required":["id","title","eventDate","signalType","state","reasoning"],"additionalProperties":false}}},"required":["id","accountId","fullName","title","note","email","emailSource","phone","linkedinUsername","linkedinUrl","location","profileImageUrl","photoUrl","isRepVisible","lastEventAt","createdAt","updatedAt"],"additionalProperties":false},"description":"People, most recently created first."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"CreatePersonDto":{"type":"object","properties":{"fullName":{"type":"string","maxLength":200},"title":{"description":"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.\n\n**Sourcing, confidence, dates and \"unverified\" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo.\n\nOmit 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.","type":"string","maxLength":200},"note":{"description":"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.\n\nYours, 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.\n\n**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.\n\nUp to 1000 characters; longer is `validation_failed`, not a truncation.","type":"string","maxLength":1000},"email":{"type":"string","format":"email","pattern":"^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"linkedinUsername":{"type":"string","maxLength":200},"accountId":{"description":"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`.\n\nSend 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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"company":{"description":"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.","type":"object","properties":{"name":{"type":"string","maxLength":200,"description":"Matched case-insensitively against your existing accounts. A match reuses that account and `domain` is ignored."},"domain":{"description":"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\"`.","type":"string","maxLength":255}},"required":["name"],"additionalProperties":false},"photoUrl":{"description":"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.","type":"string","maxLength":2000},"id":{"description":"Accepted and IGNORED — declared only so a `POST /v1/find` contacts result can be posted here verbatim. Never persisted or echoed back.","type":"string","maxLength":200},"linkedinUrl":{"description":"A LinkedIn PROFILE URL (`https://www.linkedin.com/in/ada-lovelace`), accepted so a `POST /v1/find` contacts result can be posted here verbatim.\n\n**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.\n\nCountry 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`.\n\nThe 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.\n\n**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.","type":"string","maxLength":500},"state":{"description":"Accepted and IGNORED — declared only so a `POST /v1/find` contacts result can be posted here verbatim. Never persisted or echoed back.","type":"string","maxLength":40}},"required":["fullName"],"description":"**Unknown fields are rejected** (`validation_failed`), not silently dropped. The fields marked \"accepted and ignored\" are the remaining members of a `POST /v1/find` contacts result, declared so a find result can be posted here verbatim.","additionalProperties":false},"PersonDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"uuid"},"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\n**Sourcing, confidence, dates and \"unverified\" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo.\n\nOmit 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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nYours, 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.\n\n**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.\n\nUp to 1000 characters; longer is `validation_failed`, not a truncation.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written by a caller or in the app; `enrichment` = found by a lookup."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"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.\n\nA 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.\n\nWhat 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.\n\n`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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"stakeholderReport":{"description":"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.\n\n`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.\n\nPresent 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`.","anyOf":[{"type":"string"},{"type":"null"}]},"events":{"description":"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.\n\nOnly 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.\n\nAt 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`.\n\n`[]` 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).\n\nPresent on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent.","type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Read the full event with `GET /v1/events/{id}`."},"title":{"type":"string"},"eventDate":{"type":"string","format":"date-time"},"signalType":{"type":"string"},"state":{"type":"string","enum":["live","dismissed"],"description":"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":{"type":"string","description":"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."}},"required":["id","title","eventDate","signalType","state","reasoning"],"additionalProperties":false}}},"required":["id","accountId","fullName","title","note","email","emailSource","phone","linkedinUsername","linkedinUrl","location","profileImageUrl","photoUrl","isRepVisible","lastEventAt","createdAt","updatedAt"],"additionalProperties":false},"BatchCreatePeopleDto":{"type":"object","properties":{"people":{"maxItems":200,"type":"array","items":{},"description":"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.\n\nA row whose `accountId`/`company` cannot be resolved is reported in `skipped`, not raised as a request error."}},"required":["people"],"additionalProperties":false},"BatchCreatePeopleResultDto":{"type":"object","properties":{"created":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"uuid"},"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\n**Sourcing, confidence, dates and \"unverified\" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo.\n\nOmit 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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nYours, 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.\n\n**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.\n\nUp to 1000 characters; longer is `validation_failed`, not a truncation.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written by a caller or in the app; `enrichment` = found by a lookup."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"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.\n\nA 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.\n\nWhat 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.\n\n`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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"stakeholderReport":{"description":"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.\n\n`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.\n\nPresent 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`.","anyOf":[{"type":"string"},{"type":"null"}]},"events":{"description":"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.\n\nOnly 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.\n\nAt 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`.\n\n`[]` 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).\n\nPresent on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent.","type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Read the full event with `GET /v1/events/{id}`."},"title":{"type":"string"},"eventDate":{"type":"string","format":"date-time"},"signalType":{"type":"string"},"state":{"type":"string","enum":["live","dismissed"],"description":"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":{"type":"string","description":"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."}},"required":["id","title","eventDate","signalType","state","reasoning"],"additionalProperties":false}}},"required":["id","accountId","fullName","title","note","email","emailSource","phone","linkedinUsername","linkedinUrl","location","profileImageUrl","photoUrl","isRepVisible","lastEventAt","createdAt","updatedAt"],"additionalProperties":false},"description":"The rows that were created, in submission order."},"skipped":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0-based position of the offending row in the submitted array."},"reason":{"type":"string","description":"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":{"description":"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.","type":"string","enum":["duplicate","identity_conflict"]},"existingId":{"description":"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.","type":"string","format":"uuid"}},"required":["index","reason"],"additionalProperties":false},"description":"Rows that were NOT created. Always inspect this: a `200` means the call succeeded, not that every row landed."},"degraded":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0-based position of this row in the submitted array."},"id":{"type":"string","format":"uuid","description":"The id of the created row this entry describes."},"codes":{"minItems":1,"type":"array","items":{"type":"string","enum":["no_account","no_identity"]},"description":"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":{"type":"string","description":"Prose explanation, for a human. Branch on `codes`, not on this."}},"required":["index","id","codes","reason"],"additionalProperties":false},"description":"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."},"updated":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"uuid"},"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\n**Sourcing, confidence, dates and \"unverified\" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo.\n\nOmit 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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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.\n\nYours, 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.\n\n**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.\n\nUp to 1000 characters; longer is `validation_failed`, not a truncation.\n\n`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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written by a caller or in the app; `enrichment` = found by a lookup."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"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.\n\nA 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.\n\nWhat 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.\n\n`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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"stakeholderReport":{"description":"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.\n\n`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.\n\nPresent 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`.","anyOf":[{"type":"string"},{"type":"null"}]},"events":{"description":"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.\n\nOnly 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.\n\nAt 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`.\n\n`[]` 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).\n\nPresent on `GET /v1/people/{id}`, and on `GET /v1/people` only when `expand` is sent.","type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Read the full event with `GET /v1/events/{id}`."},"title":{"type":"string"},"eventDate":{"type":"string","format":"date-time"},"signalType":{"type":"string"},"state":{"type":"string","enum":["live","dismissed"],"description":"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":{"type":"string","description":"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."}},"required":["id","title","eventDate","signalType","state","reasoning"],"additionalProperties":false}},"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0-based position of the row in the submitted array."}},"required":["id","accountId","fullName","title","note","email","emailSource","phone","linkedinUsername","linkedinUrl","location","profileImageUrl","photoUrl","isRepVisible","lastEventAt","createdAt","updatedAt","index"],"additionalProperties":false},"description":"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`."}},"required":["created","skipped","degraded","updated"],"additionalProperties":false},"UpdatePersonDto":{"type":"object","properties":{"fullName":{"type":"string","maxLength":200},"title":{"description":"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.\n\n**Sourcing, confidence, dates and \"unverified\" go in `note`**, which exists for exactly that, holds 1000 characters, and is never interpreted by Trayo.\n\nOmit 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.","type":"string","maxLength":200},"note":{"description":"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.\n\nYours, 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.\n\n**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.\n\nUp to 1000 characters; longer is `validation_failed`, not a truncation.\n\nSend `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.","anyOf":[{"type":"string","maxLength":1000},{"type":"null"}]},"email":{"type":"string","format":"email","pattern":"^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"linkedinUsername":{"type":"string","maxLength":200}},"description":"Every field is optional; only fields present are changed. `accountId` and `company` are absent on purpose — an update cannot move a person between accounts.","additionalProperties":false},"BatchEnrichBodyDto":{"type":"object","properties":{"personIds":{"minItems":1,"maxItems":200,"type":"array","items":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"description":"1 to 200 person ids. Over the cap is an error, not a clamp."},"fields":{"description":"Which values to look up. Omit for both. A field that cannot or need not run is reported in `skipped`, never an error.","minItems":1,"maxItems":2,"type":"array","items":{"type":"string","enum":["email","phone"]}}},"required":["personIds"],"additionalProperties":false},"BatchEnrichResultDto":{"type":"object","properties":{"accepted":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"started":{"type":"array","items":{"type":"string","enum":["email","phone"]},"description":"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":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["email","phone"]},"reason":{"type":"string","enum":["manual_present","already_found","in_progress","retry_too_soon","start_failed"],"description":"`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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["field","reason"],"additionalProperties":false},"description":"The lookups this call did NOT start, and why. Never an error."},"contact":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"email":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The email to use: a manual value if one was written, else the found one."},"source":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written through this API or the app; `enrichment` = found."},"status":{"anyOf":[{"type":"string","enum":["verified","unconfirmed","bounced","stale"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["match","conflict","unknown"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lookup":{"type":"object","properties":{"state":{"type":"string","enum":["not_requested","in_progress","found","not_found","error","bounced"],"description":"`not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch."},"retryAvailableAt":{"description":"Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["state","retryAvailableAt"],"additionalProperties":false}},"required":["value","source","status","identityConfidence","foundAt","lookup"],"additionalProperties":false},"phone":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The found phone number, or null."},"foundAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lookup":{"type":"object","properties":{"state":{"type":"string","enum":["not_requested","in_progress","found","not_found","error","bounced"],"description":"`not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch."},"retryAvailableAt":{"description":"Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["state","retryAvailableAt"],"additionalProperties":false}},"required":["value","foundAt","lookup"],"additionalProperties":false}},"required":["personId","email","phone"],"additionalProperties":false,"description":"The state immediately after those lookups started."}},"required":["personId","started","skipped","contact"],"additionalProperties":false},"description":"One outcome per person that exists in your workspace, in submission order."},"skipped":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0-based position in the submitted `personIds`."},"reason":{"type":"string"}},"required":["index","reason"],"additionalProperties":false},"description":"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."},"stoppedBy":{"description":"Present when a workspace allowance ended the batch before every person was evaluated. Branch on this rather than on `skipped[].reason` prose.","type":"object","properties":{"reason":{"type":"string","enum":["lookup_limit_reached","quota_daily_limit","quota_exceeded"],"description":"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":{"type":"string","enum":["retry_later","do_not_retry"],"description":"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":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many submitted people this call evaluated before the stop (they are in `accepted`)."},"skippedOther":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"string","description":"The refusal, in words, with what to do next."}},"required":["reason","retry","processed","skippedOther","notLookedUp","message"],"additionalProperties":false},"quota":{"type":"object","properties":{"email":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false},"phone":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false}},"required":["email","phone"],"additionalProperties":false,"description":"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."}},"required":["accepted","skipped"],"description":"**Partial success is the contract.** A person whose fields were all skipped still lands in `accepted`, with the reasons; only a person the call could not evaluate lands in `skipped`.","additionalProperties":false},"EnrichPersonBodyDto":{"type":"object","properties":{"fields":{"description":"Which values to look up. Omit for both. A field that cannot or need not run is reported in `skipped`, never an error.","minItems":1,"maxItems":2,"type":"array","items":{"type":"string","enum":["email","phone"]}}},"additionalProperties":false},"EnrichPersonResultDto":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"started":{"type":"array","items":{"type":"string","enum":["email","phone"]},"description":"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":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["email","phone"]},"reason":{"type":"string","enum":["manual_present","already_found","in_progress","retry_too_soon","start_failed"],"description":"`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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["field","reason"],"additionalProperties":false},"description":"The lookups this call did NOT start, and why. Never an error."},"contact":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"email":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The email to use: a manual value if one was written, else the found one."},"source":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written through this API or the app; `enrichment` = found."},"status":{"anyOf":[{"type":"string","enum":["verified","unconfirmed","bounced","stale"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["match","conflict","unknown"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lookup":{"type":"object","properties":{"state":{"type":"string","enum":["not_requested","in_progress","found","not_found","error","bounced"],"description":"`not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch."},"retryAvailableAt":{"description":"Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["state","retryAvailableAt"],"additionalProperties":false}},"required":["value","source","status","identityConfidence","foundAt","lookup"],"additionalProperties":false},"phone":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The found phone number, or null."},"foundAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lookup":{"type":"object","properties":{"state":{"type":"string","enum":["not_requested","in_progress","found","not_found","error","bounced"],"description":"`not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch."},"retryAvailableAt":{"description":"Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["state","retryAvailableAt"],"additionalProperties":false}},"required":["value","foundAt","lookup"],"additionalProperties":false}},"required":["personId","email","phone"],"additionalProperties":false,"description":"The state immediately after those lookups started."},"quota":{"type":"object","properties":{"email":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false},"phone":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false}},"required":["email","phone"],"additionalProperties":false,"description":"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."}},"required":["personId","started","skipped","contact"],"additionalProperties":false},"ContactStatusDto":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"email":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The email to use: a manual value if one was written, else the found one."},"source":{"anyOf":[{"type":"string","enum":["manual","enrichment"]},{"type":"null"}],"description":"`manual` = written through this API or the app; `enrichment` = found."},"status":{"anyOf":[{"type":"string","enum":["verified","unconfirmed","bounced","stale"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["match","conflict","unknown"]},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lookup":{"type":"object","properties":{"state":{"type":"string","enum":["not_requested","in_progress","found","not_found","error","bounced"],"description":"`not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch."},"retryAvailableAt":{"description":"Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["state","retryAvailableAt"],"additionalProperties":false}},"required":["value","source","status","identityConfidence","foundAt","lookup"],"additionalProperties":false},"phone":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The found phone number, or null."},"foundAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lookup":{"type":"object","properties":{"state":{"type":"string","enum":["not_requested","in_progress","found","not_found","error","bounced"],"description":"`not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch."},"retryAvailableAt":{"description":"Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["state","retryAvailableAt"],"additionalProperties":false}},"required":["value","foundAt","lookup"],"additionalProperties":false}},"required":["personId","email","phone"],"additionalProperties":false},"AskPersonBodyDto":{"type":"object","properties":{"question":{"type":"string","minLength":1,"maxLength":2000,"description":"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":{"description":"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.","type":"string","minLength":1,"maxLength":2000}},"required":["question"],"additionalProperties":false},"AskPersonResponseDto":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"The person this answer is about — the id you sent in the path."},"question":{"type":"string","description":"Your question, as answered."},"intent":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your intent, as applied; null when you sent none."},"answer":{"type":"string","description":"The answer, in Markdown. Grounded in the dossier only: a fact the dossier does not hold is named as unknown, never guessed."},"gaps":{"type":"array","items":{"type":"string"},"description":"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":{"type":"object","properties":{"trayo":{"type":"object","properties":{"contact":{"type":"object","properties":{"email":{"type":"boolean","description":"An email is on record (written or found)."},"phone":{"type":"boolean","description":"A found phone is on record."}},"required":["email","phone"],"additionalProperties":false},"account":{"type":"boolean","description":"The person is linked to an account and its record was included."},"accountResearch":{"type":"boolean","description":"That account carries research Trayo has gathered for it, and it was included."},"events":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Signals (events) the person is attached to, included."},"sequences":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Sequences composed for the person, included."},"emailsSent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Emails sent to the person, included."},"composedMessages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"One-off messages composed and copied for the person, included."},"lists":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Lists the person is on, included."},"synthesis":{"type":"boolean","description":"A stored person synthesis exists and was included."},"stakeholderReport":{"type":"boolean","description":"A stored stakeholder report exists and was included."},"note":{"type":"boolean","description":"The `note` you wrote on the person was included."}},"required":["contact","account","accountResearch","events","sequences","emailsSent","composedMessages","lists","synthesis","stakeholderReport","note"],"additionalProperties":false,"description":"Which workspace records went into the prompt."},"linkedin":{"type":"object","properties":{"status":{"type":"string","enum":["found","not_found","not_linked","unavailable"],"description":"`found`: the profile was included. `not_found`: the person is linked to LinkedIn but Trayo has no record. `not_linked`: the person has no LinkedIn identity in Trayo — set `linkedinUsername` with `PATCH /v1/people/{id}`. `unavailable`: the data could not be reached this time; the answer used Trayo data only."},"matchedBy":{"anyOf":[{"type":"string","enum":["stored_id","linkedin_username"]},{"type":"null"}],"description":"How the profile was found: `stored_id`, the id Trayo stored when it collected the person; `linkedin_username`, a lookup by the LinkedIn handle. Null unless `status` is `found`."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The profile URL, when found."},"experience":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Work-history entries included."},"posts":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recent posts included."}},"required":["status","matchedBy","linkedinUrl","experience","posts"],"additionalProperties":false,"description":"Whether the LinkedIn profile from Trayo's own data went into the prompt, and how much of it."}},"required":["trayo","linkedin"],"additionalProperties":false,"description":"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."}},"required":["personId","question","intent","answer","gaps","sources"],"additionalProperties":false},"FindRequestDto":{"type":"object","properties":{"target":{"type":"string","enum":["companies","people"],"description":"What to return: `companies` or `people`. People are returned with their current company."},"mode":{"description":"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.","type":"string","const":"direct"},"query":{"description":"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`).","type":"string","minLength":1,"maxLength":1000},"filters":{"description":"Exact attributes to match. Never interpreted, never fail-open. Alone, `industries`, `hq.cities`, `hq.states`, `fundingStages` and `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.","type":"object","properties":{"industries":{"description":"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, unless `fundingStages` is sent: then each company is checked against them. Exact: a company with no value for this attribute does NOT match.","minItems":1,"maxItems":99,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"headcount":{"description":"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}`). A funding stage selects a set on its own; send no profile industries or band beside it. 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.","type":"object","properties":{"min":{"type":"integer","minimum":0,"maximum":10000000},"max":{"type":"integer","minimum":1,"maximum":10000000}},"additionalProperties":false},"hq":{"description":"Where the company is headquartered, not where any person is based. For where a person is based, use `filters.person` on a people search (`target: people`, no `query`).","type":"object","properties":{"countries":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":60,"type":"array","items":{"type":"string","minLength":2,"maxLength":80}},"regions":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":6,"type":"array","items":{"type":"string","enum":["north_america","europe","asia_pacific","latin_america","middle_east","africa"]}},"cities":{"description":"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. 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.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"states":{"description":"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.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}}},"additionalProperties":false},"person":{"description":"`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`, `fundingStages`, `headcount`). Applied before `perCompany`, so each company still returns its most senior people who match.","type":"object","properties":{"countries":{"description":"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.","minItems":1,"maxItems":60,"type":"array","items":{"type":"string","minLength":2,"maxLength":80}},"states":{"description":"State or province the person is in, full name, case-insensitive (`\"california\"`, not `\"CA\"`). At most 20.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"cities":{"description":"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.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}}},"additionalProperties":false},"fundingStages":{"description":"The most recent funding round Trayo recorded, as a closed set: `pre_seed`, `seed`, `series_a`, `series_b`, `series_c`, `series_d_plus`, `private_equity`, `debt`, `post_ipo`, `grant`. Only a minority of companies carry funding data at all, so this keeps ONLY companies with a recorded round of that kind — an unfunded or unrecorded company never matches. It is a record, not a stage of life: an old or listed company can carry a stale `seed`. For \"early-stage\", pair it with `foundedYear` and `headcount`. With a `query`, the sentence path's notion of stage is checked against this record and loses: a company recorded as \"Series B\" that has since raised a Series C is dropped. Selects a set on its own, with or without a title. Beside it, `industries` are checked company by company: do not add profile industries the user did not ask for.","minItems":1,"maxItems":10,"type":"array","items":{"type":"string","enum":["pre_seed","seed","series_a","series_b","series_c","series_d_plus","private_equity","debt","post_ipo","grant"]}},"foundedYear":{"description":"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`, `fundingStages` 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 five beside it.","type":"object","properties":{"min":{"type":"integer","minimum":1000,"maximum":2100},"max":{"type":"integer","minimum":1000,"maximum":2100}},"additionalProperties":false},"technologies":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":80}},"companyTypes":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":8,"type":"array","items":{"type":"string","enum":["privately held","public company","nonprofit","educational","government agency","partnership","self-employed","self-owned"]}},"companyIds":{"description":"**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`.","minItems":1,"maxItems":200,"type":"array","items":{"type":"string","maxLength":18,"pattern":"^[1-9]\\d*$"}},"companyWebsites":{"description":"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`.","minItems":1,"maxItems":100,"type":"array","items":{"type":"string","minLength":4,"maxLength":253,"pattern":"^(?:[Hh][Tt][Tt][Pp][Ss]?:\\/\\/)?[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+\\.?(?:[/?#].*)?$"}},"title":{"description":"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.","type":"object","properties":{"any":{"description":"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.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}},"all":{"description":"Title terms that must ALL appear. Combine with `any` freely.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}},"exclude":{"description":"Title terms that disqualify a person (`[\"assistant\", \"intern\"]`).","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}}},"additionalProperties":false},"excludeWorkspace":{"description":"`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`.","type":"boolean"}},"additionalProperties":false},"sort":{"description":"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`.","type":"string","enum":["largest_first","smallest_first"]},"perCompany":{"description":"`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.","type":"integer","minimum":1,"maximum":25},"limit":{"description":"Page size. Default 25, at most 100. A search with a `query` returns at most 50.","type":"integer","minimum":1,"maximum":100},"cursor":{"description":"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.","type":"string","maxLength":64}},"required":["target"],"examples":[{"target":"people","filters":{"industries":["software development"],"hq":{"regions":["europe"]},"headcount":{"min":10,"max":200},"fundingStages":["seed","series_a"],"foundedYear":{"min":2019},"title":{"any":["founder","cto"],"exclude":["assistant"]}},"perCompany":2,"limit":25}],"additionalProperties":false},"FindResponseDto":{"type":"object","properties":{"target":{"type":"string","enum":["companies","people"]},"data":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Identifies this row: the Trayo company id when `known` is true, otherwise an id unique to this response only. You do not have to carry it around — to look these companies up again, or to search their people, send `website` back as `filters.companyWebsites`. For a direct record without a website, send its id as filters.companyIds."},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Primary website host, e.g. `stripe.com`. Send it back as `filters.companyWebsites` to search this company again, with no `query` beside it. Null when Trayo holds no website for it."},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"`https://` + `website`, ready to send as `url` to `POST /v1/accounts`. Null when the website is unknown."},"logoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company logo URL, derived from `website`, so null when the website is unknown. Usable directly as an `<img src>` and needs no API key; it answers 404 when no logo resolves."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The taxonomy value, as Trayo holds it."},"headcount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"hq":{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"state":{"anyOf":[{"type":"string"},{"type":"null"}]},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO-2."}},"required":["city","state","country"],"additionalProperties":false},"fundingStage":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A `fundingStages` id when the most recent recorded round maps to one, else the value Trayo holds, verbatim. Null when no round is recorded."},"foundedYear":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"companyType":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"One of `companyTypes`, or null."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The description Trayo holds, cut at 500 characters."},"why":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Why this company matched the `query`, in Trayo's words. Null on a filters-only search."},"known":{"type":"boolean","description":"Whether this row is backed by a profile Trayo holds. Always true on a filters-only search. On a sentence search, false means the search found a company Trayo does not hold: the attributes are as the search reported them, `id` is not one of our own ids, and — because filters are exact — the row would not have survived any `filters`."},"state":{"type":"string","enum":["selectable","alreadyAdded"],"description":"`alreadyAdded` means this record is already in your workspace."}},"required":["id","name","website","url","logoUrl","linkedinUrl","industry","headcount","hq","fundingStage","foundedYear","companyType","description","why","known","state"],"additionalProperties":false}},{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Trayo person id on a filters-only search; an id unique to this response on a sentence search."},"fullName":{"anyOf":[{"type":"string"},{"type":"null"}]},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Current title, as it is recorded for them."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their LinkedIn profile. Send it to `POST /v1/contacts/lookup`, `POST /v1/research/person`, or as `linkedinUrl` to `POST /v1/people`."},"linkedinUsername":{"anyOf":[{"type":"string"},{"type":"null"}]},"photoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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. Filled on a filters-only search; null on a sentence search."},"company":{"anyOf":[{"type":"object","properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Trayo company id, when known."},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Primary website host."},"logoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company logo URL, derived from `website`. Null when the website is unknown."},"industry":{"anyOf":[{"type":"string"},{"type":"null"}]},"headcount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"hq":{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}]},"state":{"anyOf":[{"type":"string"},{"type":"null"}]},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO-2."}},"required":["city","state","country"],"additionalProperties":false},"fundingStage":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A `fundingStages` id when the most recent recorded round maps to one, else the value Trayo holds, verbatim. Null when no round is recorded."},"foundedYear":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]}},"required":["id","name","website","logoUrl","industry","headcount","hq","fundingStage","foundedYear"],"additionalProperties":false},{"type":"null"}],"description":"Their current employer, with the attributes the company filters read, so a row can be checked against the request. Complete on a filters-only search, and on a sentence search sent with company filters, where it is the employer those filters were checked against. On a sentence search without them usually name-only, because a sentence search returns a company reference without a website."},"state":{"type":"string","enum":["selectable","alreadyAdded"],"description":"`alreadyAdded` means this record is already in your workspace."}},"required":["id","fullName","title","linkedinUrl","linkedinUsername","photoUrl","location","company","state"],"additionalProperties":false}}],"description":"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":{"type":"boolean","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"truncated":{"type":"boolean","description":"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":{"type":"boolean","description":"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":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"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":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","enum":["filter_values_truncated","city_spans_multiple_places","search_degraded","per_company_limit_reached","title_matched_first","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"],"description":"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). `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; industries beside `fundingStages`; 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 (beside `fundingStages`: a headcount band, countries 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. `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`. `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":{"description":"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.","type":"string"},"values":{"description":"The values of that field the note applies to, as they were sent. Absent whenever `filter` is.","type":"array","items":{"type":"string"}},"detail":{"type":"string","description":"One sentence naming what was found, for a human reading the response."}},"required":["code","detail"],"additionalProperties":false},"description":"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."},"applied":{"anyOf":[{"type":"object","properties":{"industries":{"description":"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, unless `fundingStages` is sent: then each company is checked against them. Exact: a company with no value for this attribute does NOT match.","minItems":1,"maxItems":99,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"headcount":{"description":"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}`). A funding stage selects a set on its own; send no profile industries or band beside it. 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.","type":"object","properties":{"min":{"type":"integer","minimum":0,"maximum":10000000},"max":{"type":"integer","minimum":1,"maximum":10000000}},"additionalProperties":false},"hq":{"description":"Where the company is headquartered, not where any person is based. For where a person is based, use `filters.person` on a people search (`target: people`, no `query`).","type":"object","properties":{"countries":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":60,"type":"array","items":{"type":"string","minLength":2,"maxLength":80}},"regions":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":6,"type":"array","items":{"type":"string","enum":["north_america","europe","asia_pacific","latin_america","middle_east","africa"]}},"cities":{"description":"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. 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.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"states":{"description":"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.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}}},"additionalProperties":false},"person":{"description":"`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`, `fundingStages`, `headcount`). Applied before `perCompany`, so each company still returns its most senior people who match.","type":"object","properties":{"countries":{"description":"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.","minItems":1,"maxItems":60,"type":"array","items":{"type":"string","minLength":2,"maxLength":80}},"states":{"description":"State or province the person is in, full name, case-insensitive (`\"california\"`, not `\"CA\"`). At most 20.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"cities":{"description":"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.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}}},"additionalProperties":false},"fundingStages":{"description":"The most recent funding round Trayo recorded, as a closed set: `pre_seed`, `seed`, `series_a`, `series_b`, `series_c`, `series_d_plus`, `private_equity`, `debt`, `post_ipo`, `grant`. Only a minority of companies carry funding data at all, so this keeps ONLY companies with a recorded round of that kind — an unfunded or unrecorded company never matches. It is a record, not a stage of life: an old or listed company can carry a stale `seed`. For \"early-stage\", pair it with `foundedYear` and `headcount`. With a `query`, the sentence path's notion of stage is checked against this record and loses: a company recorded as \"Series B\" that has since raised a Series C is dropped. Selects a set on its own, with or without a title. Beside it, `industries` are checked company by company: do not add profile industries the user did not ask for.","minItems":1,"maxItems":10,"type":"array","items":{"type":"string","enum":["pre_seed","seed","series_a","series_b","series_c","series_d_plus","private_equity","debt","post_ipo","grant"]}},"foundedYear":{"description":"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`, `fundingStages` 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 five beside it.","type":"object","properties":{"min":{"type":"integer","minimum":1000,"maximum":2100},"max":{"type":"integer","minimum":1000,"maximum":2100}},"additionalProperties":false},"technologies":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":80}},"companyTypes":{"description":"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`, `fundingStages` 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 five beside it.","minItems":1,"maxItems":8,"type":"array","items":{"type":"string","enum":["privately held","public company","nonprofit","educational","government agency","partnership","self-employed","self-owned"]}},"companyIds":{"description":"**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`.","minItems":1,"maxItems":200,"type":"array","items":{"type":"string","maxLength":18,"pattern":"^[1-9]\\d*$"}},"companyWebsites":{"description":"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`.","minItems":1,"maxItems":100,"type":"array","items":{"type":"string","minLength":4,"maxLength":253,"pattern":"^(?:[Hh][Tt][Tt][Pp][Ss]?:\\/\\/)?[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+\\.?(?:[/?#].*)?$"}},"title":{"description":"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.","type":"object","properties":{"any":{"description":"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.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}},"all":{"description":"Title terms that must ALL appear. Combine with `any` freely.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}},"exclude":{"description":"Title terms that disqualify a person (`[\"assistant\", \"intern\"]`).","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}}},"additionalProperties":false},"excludeWorkspace":{"description":"`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`.","type":"boolean"}},"additionalProperties":false},{"type":"null"}],"description":"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."},"resolvedWebsites":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"website":{"type":"string","description":"The website as it was matched: lowercased, without the scheme, `www.` or any path."},"id":{"type":"string","description":"The company profile this website resolved to, so two entries can be told apart. Read `name` to check the pick; you name companies to this route by their websites."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"That company, as Trayo names it."},"headcount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Its headcount. When the domain carries several profiles whose names match it, the largest wins."}},"required":["website","id","name","headcount"],"additionalProperties":false}},{"type":"null"}],"description":"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":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"website":{"type":"string","description":"The website as it was matched: lowercased, without the scheme, `www.` or any path. A value with no usable host is echoed as you sent it."},"reason":{"type":"string","enum":["not_found","uncertain"],"description":"Why it was left out. `not_found`: Trayo holds no company on this domain, or the value has no usable host. Check the spelling, send the company's own domain, or describe the company with filters instead. `uncertain`: Trayo holds company profiles on this domain, but none clearly is the company the website belongs to (no name matching the domain, or a much larger profile under another name beside it), so none was searched rather than risk the wrong one. The candidates are in `alternatives`: show them to the user, or, if one is clearly right, search again with `filters.companyIds` set to that alternative's `id`. New reasons may be added; treat an unknown one as not searched."},"alternatives":{"description":"Only for `reason: \"uncertain\"`: the profiles this website could name, at most 3, largest first. Absent for other reasons.","maxItems":3,"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The company id. Send it in `filters.companyIds` (instead of `filters.companyWebsites`) to search this exact company."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"That company, as Trayo names it."},"headcount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Its headcount, when known."}},"required":["id","name","headcount"],"additionalProperties":false}}},"required":["website","reason"],"additionalProperties":false}},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"query":{"type":"string","description":"How the sentence was rewritten before searching."},"filters":{"type":"object","properties":{"industries":{"type":"array","items":{"type":"string"}},"headcount":{"type":"object","properties":{"min":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"max":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"additionalProperties":false},"hq":{"type":"object","properties":{"countries":{"type":"array","items":{"type":"string"}},"cities":{"type":"array","items":{"type":"string"}},"states":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"fundingStages":{"type":"array","items":{"type":"string"}},"technologies":{"type":"array","items":{"type":"string"}}},"additionalProperties":false,"description":"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."}},"required":["query","filters"],"additionalProperties":false},{"type":"null"}],"description":"Present when a `query` was sent; null on a filters-only search."}},"required":["target","data","hasMore","nextCursor","truncated","degraded","droppedUnaddressable","notes","applied","resolvedWebsites","unresolvedWebsites","interpreted"],"additionalProperties":false},"IndustriesResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"},"description":"Industry values, lowercase, exactly as `filters.industries` expects them."}},"required":["data"],"additionalProperties":false},"JobChangeSearchBodyDto":{"type":"object","properties":{"source":{"description":"The company the person left.","type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"accountId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"destination":{"description":"The company the person joined.","type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"accountId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"detectedSince":{"description":"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.","type":"string"},"seniority":{"description":"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.","type":"string","enum":["cxo","vp","director","head"]},"limit":{"description":"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.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cursor":{"description":"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.","type":"string"},"title":{"description":"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.","type":"object","properties":{"any":{"description":"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.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}},"all":{"description":"Title terms that must ALL appear. Combine with `any` freely.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}},"exclude":{"description":"Title terms that disqualify a person (`[\"assistant\", \"intern\"]`).","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":1,"maxLength":120}}},"additionalProperties":false},"startedSince":{"description":"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.","type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\\d|3[01]))?$"},"startedBefore":{"description":"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.","type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\\d|3[01]))?$"},"destinationFilters":{"description":"With `title` only. Narrows the moves by the company they JOINED: `hq.countries` and `headcount`.","type":"object","properties":{"hq":{"type":"object","properties":{"countries":{"minItems":1,"maxItems":60,"type":"array","items":{"type":"string","minLength":2,"maxLength":80},"description":"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."}},"required":["countries"],"additionalProperties":false},"headcount":{"description":"Inclusive employee-count band of the company they JOINED, e.g. `{\"min\": 500}`. A company with no recorded size does not match.","type":"object","properties":{"min":{"type":"integer","minimum":0,"maximum":10000000},"max":{"type":"integer","minimum":1,"maximum":10000000}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"JobChangeSearchResponseDto":{"type":"object","properties":{"source":{"anyOf":[{"type":"object","properties":{"companyId":{"type":"string","description":"Trayo's id for the company. Send it back as `companyId` to name this exact company again."},"name":{"type":"string","description":"The company as Trayo records it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's website domain as Trayo files it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo has it."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"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":{"type":"string","enum":["account_match","company_id","linkedin","website","redirect","name"],"description":"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.\n\nA 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":{"anyOf":[{"type":"string","enum":["exact","approximate"]},{"type":"null"}],"description":"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."}},"required":["companyId","name","website","linkedinUrl","accountId","resolvedBy","nameMatch"],"additionalProperties":false},{"type":"null"}],"description":"The `source` company you sent, as resolved. Null when you sent none."},"destination":{"anyOf":[{"type":"object","properties":{"companyId":{"type":"string","description":"Trayo's id for the company. Send it back as `companyId` to name this exact company again."},"name":{"type":"string","description":"The company as Trayo records it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's website domain as Trayo files it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo has it."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"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":{"type":"string","enum":["account_match","company_id","linkedin","website","redirect","name"],"description":"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.\n\nA 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":{"anyOf":[{"type":"string","enum":["exact","approximate"]},{"type":"null"}],"description":"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."}},"required":["companyId","name","website","linkedinUrl","accountId","resolvedBy","nameMatch"],"additionalProperties":false},{"type":"null"}],"description":"The `destination` company you sent, as resolved. Null when you sent none."},"jobChanges":{"type":"array","items":{"type":"object","properties":{"person":{"type":"object","properties":{"fullName":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's name, as Trayo holds it."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their title in the role they joined."},"headline":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their LinkedIn headline."},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Where they are based, as Trayo holds it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their LinkedIn profile. Send it straight to `POST /v1/contacts/lookup` or `POST /v1/research/person`."},"linkedinUsername":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The handle from that profile URL."},"photoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["fullName","title","headline","location","linkedinUrl","linkedinUsername","photoUrl"],"additionalProperties":false},"source":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company, as Trayo records it."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The title the person held there."}},"required":["name","title"],"additionalProperties":false,"description":"The company they left, and their title there."},"destination":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company, as Trayo records it."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The title the person held there."}},"required":["name","title"],"additionalProperties":false,"description":"The company they joined, and their title there."},"detectedAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$","description":"When Trayo first observed the new role — not when the person started it. Rows come back most recently detected first."},"startedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["month","year"]},{"type":"null"}],"description":"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."}},"required":["person","source","destination","detectedAt","startedAt","startedAtPrecision"],"additionalProperties":false},"description":"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`)."},"hasMore":{"type":"boolean","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"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."}},"required":["source","destination","jobChanges","hasMore","nextCursor","sampled"],"additionalProperties":false},"PostSearchBodyDto":{"type":"object","properties":{"topic":{"description":"What the posts should be about. Omit when searching all posts by an author.","type":"string","minLength":1,"maxLength":300},"author":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"windowDays":{"default":30,"description":"How many days back to search when publishedFrom and publishedTo are omitted, 1 to 30; default 30.","type":"integer","minimum":1,"maximum":30},"publishedFrom":{"description":"First publication date to include, YYYY-MM-DD. Send with publishedTo; overrides windowDays.","type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},"publishedTo":{"description":"Last publication date to include, YYYY-MM-DD. Send with publishedFrom; overrides windowDays.","type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},"limit":{"default":25,"description":"Maximum posts per page. Follow nextCursor with the same search filters until hasMore is false; page size may change.","type":"integer","minimum":1,"maximum":50},"cursor":{"description":"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.","type":"string","minLength":1,"maxLength":1024}},"additionalProperties":false},"PostSearchResponseDto":{"type":"object","properties":{"windowDays":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"The relative window searched, or null for explicit publication dates."},"since":{"type":"string","description":"The earliest publication date searched, YYYY-MM-DD."},"until":{"type":"string","description":"The last publication date searched, YYYY-MM-DD."},"hasMore":{"type":"boolean","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Send back as cursor with the same search filters. Null at the end. Do not compute or modify it."},"coverage":{"type":"object","properties":{"mode":{"type":"string","enum":["author_live","ranked_snapshot"]},"scope":{"type":"string","enum":["stored_matches","bounded_ranked_candidates"]},"candidateLimit":{"anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"null"}]},"snapshotExpiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"droppedLowRelevance":{"anyOf":[{"type":"integer","minimum":0,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"type":"boolean","description":"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":{"type":"boolean","description":"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":{"description":"What limited this search, in a sentence to pass on, or null when nothing did.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["mode","scope","candidateLimit","snapshotExpiresAt","droppedLowRelevance"],"additionalProperties":false,"description":"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."},"posts":{"type":"array","items":{"type":"object","properties":{"postId":{"type":"string","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The post on LinkedIn."},"publishedAt":{"type":"string","description":"Publication date, YYYY-MM-DD."},"text":{"type":"string","description":"The post text, cut at 600 characters."},"author":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"fullName":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's name. The same value as `name`, under the name every person-returning route uses."},"headline":{"anyOf":[{"type":"string"},{"type":"null"}]},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The author's current job title, where we know it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The author's LinkedIn profile URL."},"linkedinUsername":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The handle from that profile URL."},"photoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["name","fullName","headline","title","linkedinUrl","linkedinUsername","photoUrl"],"additionalProperties":false},"company":{"anyOf":[{"type":"object","properties":{"companyId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Company dataset ID, where known. Reuse it as `companyId` in POST /v1/accounts/lookalikes. Send it as `company.companyId` to company research, stakeholder search or job-change search to name this exact company. Null when Trayo holds no company record for the employer: `name` is then the free text from the LinkedIn profile, and looking it up by name will usually miss; search for it with a find companies `query` instead."},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo holds a record for it."},"employees":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Employee count, where known."},"country":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["companyId","name","website","linkedinUrl","employees","country"],"additionalProperties":false},{"type":"null"}],"description":"The author's current company, or null when it is not known."},"commentCount":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Comments we hold for this post."}},"required":["postId","url","publishedAt","text","author","company","commentCount"],"additionalProperties":false},"description":"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."}},"required":["windowDays","since","until","hasMore","nextCursor","coverage","posts"],"additionalProperties":false},"PostEngagersBodyDto":{"type":"object","properties":{"post":{"type":"string","minLength":1,"maxLength":2048,"pattern":"^\\d{15,20}$|activity[-:]\\d{15,20}|linkedin\\.com\\/posts\\/[a-z0-9_-]+-share-\\d{15,20}-[a-z0-9_-]+","description":"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."}},"required":["post"],"additionalProperties":false},"PostEngagersResponseDto":{"type":"object","properties":{"postId":{"type":"string"},"url":{"anyOf":[{"type":"string"},{"type":"null"}]},"indexed":{"type":"boolean","description":"Whether Trayo found the post. False is not evidence that the post has no comments."},"reason":{"anyOf":[{"type":"string","enum":["outside_coverage","not_held"]},{"type":"null"}],"description":"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."},"coverageFrom":{"type":"string","description":"The earliest publication date Trayo keeps posts for, YYYY-MM-DD. It moves forward daily."},"commentsChecked":{"type":"boolean","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"array","items":{"type":"object","properties":{"role":{"type":"string","enum":["author","commenter"]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"fullName":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's name. The same value as `name`, under the name every person-returning route uses."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's LinkedIn profile URL."},"linkedinUsername":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The handle from that profile URL."},"photoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their LinkedIn headline as shown on the post, often null for commenters."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their current job title, where we know it."},"company":{"anyOf":[{"type":"object","properties":{"companyId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Company dataset ID, where known. Reuse it as `companyId` in POST /v1/accounts/lookalikes. Send it as `company.companyId` to company research, stakeholder search or job-change search to name this exact company. Null when Trayo holds no company record for the employer: `name` is then the free text from the LinkedIn profile, and looking it up by name will usually miss; search for it with a find companies `query` instead."},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["companyId","name","website","linkedinUrl"],"additionalProperties":false},{"type":"null"}],"description":"Their current company, where we know it."},"comment":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their first comment on the post. Null for the author."},"commentedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"When that comment was made, ISO 8601. Null for the author."},"commentCount":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"How many comments this person left on the post. For the author, how many replies they left in the thread."},"reactionCount":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Reactions on their first comment. Null for the author."}},"required":["role","name","fullName","linkedinUrl","linkedinUsername","photoUrl","headline","title","company","comment","commentedAt","commentCount","reactionCount"],"additionalProperties":false},"description":"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."},"truncated":{"type":"boolean","description":"True when Trayo holds more than 500 comment rows and only the first were read. False does not mean every LinkedIn comment was collected."}},"required":["postId","url","indexed","reason","coverageFrom","commentsChecked","commentsLoadedAt","engagers","truncated"],"additionalProperties":false},"PostKeywordSearchBodyDto":{"type":"object","properties":{"concepts":{"minItems":1,"maxItems":4,"type":"array","items":{"minItems":1,"maxItems":5,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"description":"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":{"description":"Exclude a candidate containing any of these whole-token phrases. Applied after the bounded candidate search.","minItems":1,"maxItems":5,"type":"array","items":{"type":"string","minLength":1,"maxLength":100}},"authorIds":{"description":"Optional person dataset IDs. Keep IDs as strings.","minItems":1,"maxItems":25,"type":"array","items":{"type":"string","pattern":"^[1-9]\\d{0,18}$"}},"windowDays":{"default":7,"description":"Publication lookback in UTC days. This is a requested window, not a guarantee of source freshness.","type":"integer","minimum":1,"maximum":7},"limit":{"default":25,"type":"integer","minimum":1,"maximum":50}},"required":["concepts"],"additionalProperties":false},"PostKeywordSearchResponseDto":{"type":"object","properties":{"applied":{"type":"object","properties":{"concepts":{"type":"array","items":{"type":"array","items":{"type":"string"}}},"excludedPhrases":{"type":"array","items":{"type":"string"}},"authorIds":{"type":"array","items":{"type":"string"}},"windowDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"limit":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["concepts","excludedPhrases","authorIds","windowDays","limit"],"additionalProperties":false},"since":{"type":"string","description":"Inclusive earliest publication date searched, YYYY-MM-DD."},"through":{"type":"string","description":"Latest publication date searched, YYYY-MM-DD."},"posts":{"type":"array","items":{"type":"object","properties":{"postId":{"type":"string"},"employeeId":{"anyOf":[{"type":"string"},{"type":"null"}]},"url":{"anyOf":[{"type":"string"},{"type":"null"}]},"publishedAt":{"type":"string"},"text":{"type":"string"},"textTruncated":{"type":"boolean"},"author":{"type":"object","properties":{"fullName":{"anyOf":[{"type":"string"},{"type":"null"}]},"headline":{"anyOf":[{"type":"string"},{"type":"null"}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["fullName","headline","linkedinUrl"],"additionalProperties":false}},"required":["postId","employeeId","url","publishedAt","text","textTruncated","author"],"additionalProperties":false}},"coverage":{"type":"object","properties":{"mode":{"type":"string","const":"bounded_ranked_candidates"},"exhaustive":{"type":"boolean","const":false,"description":"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":{"type":"number","const":7},"minimumIndexedBodyCharacters":{"type":"number","const":120,"description":"The recent-post index omits shorter bodies. Older posts and sources not yet indexed are also outside coverage."},"candidateLimit":{"type":"number","const":200},"candidatesExamined":{"type":"integer","minimum":0,"maximum":9007199254740991},"candidateCapReached":{"type":"boolean","description":"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":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Matches among examined candidates, not a global total."},"resultsTruncated":{"type":"boolean","description":"The candidate cap or result limit trimmed this sample. A false value does not make retrieval exhaustive."},"order":{"type":"string","const":"lexical_relevance_within_sample","description":"Keyword relevance within the examined sample; not a global ranking of all stored posts."}},"required":["mode","exhaustive","maxWindowDays","minimumIndexedBodyCharacters","candidateLimit","candidatesExamined","candidateCapReached","matchingPostsInSample","resultsTruncated","order"],"additionalProperties":false}},"required":["applied","since","through","posts","coverage"],"additionalProperties":false},"EvidenceSearchBodyDto":{"type":"object","properties":{"sources":{"minItems":1,"maxItems":4,"type":"array","items":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"description":"Which sources to search, 1 to 4; one call searches every source named, at once. `job_postings`: job postings (about the last 60 days; while they are searched, the workspace's company and people searches may answer `data_search_busy`). `sec_filings`: the full text of SEC filings (SEC registrants only). `trade_court_cases`: US Court of International Trade cases; court membership alone does not establish a tariff-refund claim. `import_records`: US import (bill of lading) records naming a care-of party (\"<importer> c/o <party>\"), not proof of its financial role; the customers a vendor shows on its own site (logos, case studies) come from POST /v1/companies/competitors."},"phrases":{"description":"Exact phrases, one per entry, any of them matches (there is no AND), e.g. a vendor's full legal name in quotes, or \"freight audit\". What each source matches: job postings, the description (with `titles`, a posting must match a title AND contain a phrase); SEC filings, the full text (quoted parts and OR inside an entry become separate phrases; leave out with `excludePhrases`, not NOT); court cases, the case name, parties and the filing text the index holds, shown in `excerpt` (many new complaints have none, so their cases are missed: for a company's cases, send it in `companies` without phrases; for every case filed in the window, send neither); import records, care-of text in the title, consignee or notify-party field, e.g. \"c/o <party>\" (a phrase without c/o is read as the party after c/o), up to 8 per search by default, the rest named in `notSearched`; with other sources, only phrases written as care-of text. Words match case-insensitively; prefer distinctive phrases.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"companies":{"description":"Up to 200 companies, each `{ accountId }` or `{ name, domain? }`; omit for a market-wide search. What each source matches: job postings, each company's own postings, read in full or named in `coverage.cutCompanies` (they also need `titles` or `phrases`; a name without `domain` reads the largest company with that exact name, in any legal form; add `domain` for a precise match); SEC filings, the filer (matched to SEC's filer list by name, ticker or domain, a short or former name included; unmatched names are listed in the source's `unresolvedCompanies`); court cases, the plaintiff (see `partyRole`) by its brand (\"Acme Technologies plc\" finds \"Acme U.S. Inc.\"; a party that only starts with the brand is marked `details.partyMatch`), every such case in the window when no phrase is sent; import records, the importer (consignee), the first 8 by default when no phrase is sent.","minItems":1,"maxItems":200,"type":"array","items":{"anyOf":[{"type":"object","properties":{"accountId":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid","description":"A workspace account id, from trayo_list_accounts or an import."}},"required":["accountId"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string","minLength":2,"maxLength":200,"description":"The company name as it is usually written."},"domain":{"description":"Its website domain, e.g. acme.com. Job postings read the company this website names, and only it.","type":"string","minLength":3,"maxLength":255}},"required":["name"],"additionalProperties":false}]}},"excludePhrases":{"description":"Drop an item whose matched text (title, excerpt, case name) contains any of these, e.g. a job title word like \"driver\".","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"excludeCompanies":{"description":"Up to 50 company names; drop items from a company whose name contains one: the vendor you research (its own postings name it), staffing agencies, job boards, your own company.","minItems":1,"maxItems":50,"type":"array","items":{"type":"string","minLength":2,"maxLength":200}},"titles":{"description":"`job_postings` only: role titles, any of them; a posting matches when its title holds every word of one, in any order (\"freight audit\" matches \"Freight Pay & Audit Analyst\"). Ignored by the other sources. Signals with no source of their own, read from postings: a function's work outsourced to a named outside firm (that function's `titles`, with `phrases` naming the firm or \"outsourced\"); the same roles at a company's own centers in other countries (those `titles`, with `countries`); a system change or vendor selection (ERP-specific `titles` such as \"S/4HANA\" or \"ERP implementation\", or function `titles` such as \"accounts payable\" with description `phrases` on implementation, cutover or transition services). Inspect the actual dated project and scope; generic duties or system experience alone do not establish a change.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"countries":{"description":"`job_postings` only: keep postings in these countries, ISO-2 codes or English names, e.g. [\"IN\", \"PL\", \"PH\"] for a company's own service centers. Ignored by the other sources.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":2,"maxLength":60}},"partyRole":{"description":"`trade_court_cases` with `companies` only: `plaintiff` (default), the cases a company brings (it leads the case name); `any`, also cases naming it as a defendant, intervenor or other party. Each item's `details.partyRole` says which. Ignored by the other sources.","type":"string","enum":["plaintiff","any"]},"forms":{"description":"`sec_filings` only: form types, e.g. 10-K, 10-Q, 8-K, 20-F, S-1. Default 10-K, 10-Q and 8-K.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","pattern":"^[0-9A-Za-z][0-9A-Za-z\\- /]{0,14}$"}},"sic":{"description":"`sec_filings` only: keep filers whose SIC industry code starts with one of these, e.g. 2 and 3 for manufacturing, 50 and 51 for wholesale, 52 to 59 for retail.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","pattern":"^\\d{1,4}$"}},"withinDays":{"default":90,"description":"How many days back to search, 1 to 365; default 90. Each source clips it to what it holds and says so in its `window` (job postings hold about 60 days). Report those dates.","type":"integer","minimum":1,"maximum":365},"limit":{"default":25,"description":"Items per source, 1 to 100; default 25.","type":"integer","minimum":1,"maximum":100},"cursor":{"description":"A source's `nextCursor`, verbatim, sent with exactly that one source and the same search fields. Continues its results.","type":"string","minLength":1,"maxLength":1000},"waitSeconds":{"description":"How long to wait for the search before answering, 0 to 45 seconds (default 30). A search still running is not an error: it answers `running` with what has finished, and continues; read it again with `searchId`.","type":"integer","minimum":0,"maximum":45}},"required":["sources"],"additionalProperties":false},"EvidenceSearchResponseDto":{"type":"object","properties":{"searchId":{"type":"string","description":"Read this search again with it, for an hour, with the credential that started it."},"status":{"type":"string","enum":["running","done"],"description":"`running`: some sources have not finished; their items arrive on a later read. `done`: every source finished."},"sources":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"status":{"type":"string","enum":["running","done","partial","failed"],"description":"`running`: not finished yet (job postings may already show a first read while it reads cut phrases again). `done`: finished. `partial`: finished with what it could read (see `coverage.note`). `failed`: nothing from this source; `error` says why and whether to retry. A failed source is never \"no matches\"."},"window":{"anyOf":[{"type":"object","properties":{"from":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The earliest date searched, YYYY-MM-DD (import records: the date before which a record is stale)."},"to":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The latest date searched, YYYY-MM-DD."},"heldFrom":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The oldest date this source holds, when it holds less than you asked for."},"requestedDays":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The `withinDays` you sent."},"limitedByData":{"type":"boolean","description":"True when the source holds less than `withinDays` back: report `from` to `to`, not the window you asked for."}},"required":["from","to","heldFrom","requestedDays","limitedByData"],"additionalProperties":false},{"type":"null"}],"description":"The dates this source covered; null until its first read answers."},"matches":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Items this source returned."},"totalMatches":{"anyOf":[{"type":"integer","minimum":0,"maximum":9007199254740991},{"type":"null"}],"description":"Matches the source counted among what it read, when it counts them (SEC: documents; job postings: jobs among the postings read, each job once however often it is listed). Not a count of the whole market: report it as \"at least\"."},"coverage":{"anyOf":[{"type":"object","properties":{"complete":{"type":"boolean","description":"False when the source read only part of what matches (a cap or a time budget): narrow the search rather than report a total."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What limited this source and how to narrow it."},"cutPhrases":{"description":"Job postings: the phrases a cap still cut, after the search read each cut phrase again on its own while time allowed; every other phrase was searched in full. Send each again as `note` says: more distinctive words or narrower `titles`, or a search of its own.","type":"array","items":{"type":"string"}},"phrases":{"description":"Job postings with `phrases`: each phrase's coverage, from its last read.","type":"array","items":{"type":"object","properties":{"phrase":{"type":"string"},"complete":{"type":"boolean","description":"Every posting holding its words was read and checked."},"searchedAlone":{"type":"boolean","description":"A cap cut it in the shared read, so it was read again on its own."},"read":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Postings read that hold all of its words (with `titles`, a title too)."},"checked":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of those, the ones decided: checked for the exact phrase, or a one-word phrase's index match."},"matched":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of those, the postings that matched."}},"required":["phrase","complete","searchedAlone","read","checked","matched"],"additionalProperties":false}},"cutCompanies":{"description":"Job postings with `companies`: the companies whose own postings a cap cut; every other one was read in full. Send each again as `note` says: in a search with fewer companies, or with narrower `titles`.","type":"array","items":{"type":"string"}}},"required":["complete","note"],"additionalProperties":false},{"type":"null"}]},"unresolvedCompanies":{"type":"array","items":{"type":"string"},"description":"`companies` this source could not match (SEC filings: names that match no filer; job postings: no company of that name)."},"notSearched":{"type":"array","items":{"type":"string"},"description":"Phrases or companies this source did not search (import records: a failed web search, the time budget, or past the paid searches one call runs). The source is `partial`: send them in another search with this one source."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Send back as `cursor` with this one source and the same fields for more. Null at the end."},"error":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"The error code, as any Trayo error carries it."},"retry":{"type":"string","description":"`retry_later`, `fix_input` or `do_not_retry`."},"message":{"type":"string"},"retryAfterSeconds":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"How long to wait before retrying, when the source said; null otherwise."}},"required":["code","retry","message","retryAfterSeconds"],"additionalProperties":false},{"type":"null"}]},"rollup":{"anyOf":[{"type":"object","properties":{"companies":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The company as the source names it: the plaintiff, the filer, the importer, the employer."},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your workspace account for it; null when it is not one of yours."},"matches":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Its matches among those counted: cases, filings, import records or jobs."},"listings":{"description":"Job postings only: the listings of those jobs (a job listed in several places is one match).","type":"integer","minimum":0,"maximum":9007199254740991},"newestDate":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD of its newest match."}},"required":["name","accountId","matches","newestDate"],"additionalProperties":false},"description":"At most 20, most matches first, then the newest."},"counted":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"The matches counted for this ranking."},"complete":{"type":"boolean","description":"True when every match was counted; false when a cap or the time stopped the count (see `note`)."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What the count covers and how to complete it."}},"required":["companies","counted","complete","note"],"additionalProperties":false},{"type":"null"}],"description":"The market view of a market-wide search (no `companies`), on its first page: the companies with the most matches over every match this source counted, not only `items`, so you need not page every row to see who leads. Null with `companies` or `cursor`."}},"required":["source","status","window","matches","totalMatches","coverage","unresolvedCompanies","notSearched","nextCursor","error","rollup"],"additionalProperties":false},"description":"One entry per source searched."},"items":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"company":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company as the source names it (employer, filer, party, importer)."},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your workspace account: one you named in `companies`, or an account with the same domain or the same name."}},"required":["name","domain","accountId"],"additionalProperties":false},"date":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD: filed; for import records, the latest shipment date found in search-index text (null when none was found), unverified against the current page; for job postings, the board-posted date when no later than the earliest stored record date, else that stored date (`details.dateBasis`). Neither establishes the original opening or a repost; `details.postedBeforeWindow` means this date precedes the window."},"title":{"type":"string","description":"The posting title, the filing (form and filer), the case name, or the import record page title."},"excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Verbatim text from the source around the match, at most about 400 characters, never a summary; a job posting's starts with its location in brackets. Import records start with an index-only, page-unverified provenance marker: the generated bracketed marker is metadata, not part of the source quotation. Quote the source text after it, or from `url`, not from memory. Null when the source gave none."},"matchedPhrases":{"type":"array","items":{"type":"string"},"description":"Every one of the `phrases` (and `titles`) this item is known to match, and only those: shown in its text, or the one phrase sent; empty when the source could not tell which (SEC filings and court cases say why in `details.excerptStatus`)."},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The item at its source: the posting, the sec.gov document, the docket, the record page. Cite it."},"sourceObservation":{"description":"Docket fields; role and scope remain qualified. Absent on old/other sources.","anyOf":[{"type":"object","properties":{"basis":{"type":"string","enum":["source_quotes","source_headline","court_docket"]},"text":{"type":"string","minLength":1,"maxLength":1200},"quotes":{"maxItems":3,"type":"array","items":{"type":"string","minLength":1,"maxLength":1200}},"scopeNote":{"type":"string","minLength":1,"maxLength":500}},"required":["basis","text","quotes","scopeNote"],"additionalProperties":false},{"type":"null"}]},"details":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"type":"string"}}]},"description":"Source-specific fields. Job postings: postingId, location (as written), city, region, country (corrected where the record's own coordinates or text show it misread them, e.g. a state code taken for a country; recordedCountry and recordedRegion then hold what the record said), remote (the role is remote; the place is where it is listed), active (stored listing status, not live-verified: true if any grouped listing is active; false: closed/removed among known statuses; null: unknown), statusBasis (stored_listing_status), listings (one job listed this many times, e.g. once per city), firstSeen/lastSeen (earliest/latest stored record dates among listings read, from created_at else date_posted, not ingestion, observation, crawl or status-check times), boardPostedAt (representative listing's board date; earliest available after country grouping), dateBasis (board_posted or stored_record_date), postedBeforeWindow, companyId, matchedTitle, matchingPostings (jobs at this employer among the postings read). SEC filings: cik, ticker, form, documentType, periodEnding, sic, location, accessionNumber, filingIndexUrl, excerptStatus, filerMatch (with `companies`: how the company matched the filer, cik, ticker, name, domain, short_name (the one listed filer whose name starts with the company's) or former_name; the last two carry no accountId and name `matchedFor`: check them), matchedFor. Court cases: docketNumber, court, dateTerminated, parties, partyRole (plaintiff, defendant, or party: named in the party list only), matchedParty, partyMatch (with `companies`: exact, the company itself; brand_prefix, a party whose name only starts with `brandOf`'s brand, a subsidiary or an unrelated namesake with no accountId: check it before counting it as that company), brandOf, natureOfSuit, cause, filingDescription, filingUrl, excerptStatus (found: the excerpt shows a phrase; not_shown: the index matched text it did not return), casePurposeStatus (not_established: no descriptive purpose text; text_available: read casePurposeSources for the actual claim and relief, not a verified legal classification), casePurposeSources, casePurposeNote. Import records: careOfParty, roleEvidence (care_of_text_only), freightPayer/payerFrom (legacy c/o-text aliases, not established financial roles; payerFrom=consignee includes notify-party text), site, dateIs (latest_shipment_in_indexed_text or not_stated), indexedShipmentDate, stale (indexed date before the window), verificationStatus (index_only: current page not read), indexResultRetrievedAt (index retrieval, not page crawl/publication), verificationNote. Dates, shipment counts in indexed text are not verified current-page facts. Even fresh c/o text alone does not establish billing/payment/audit service, a current contract, budget or renewal."}},"required":["source","company","date","title","excerpt","matchedPhrases","url","details"],"additionalProperties":false},"description":"Evidence found so far, grouped by source, each source best first. Nothing is saved to your workspace."},"companies":{"type":"array","items":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}]},"matches":{"type":"object","properties":{"job_postings":{"type":"integer","minimum":0,"maximum":9007199254740991},"sec_filings":{"type":"integer","minimum":0,"maximum":9007199254740991},"trade_court_cases":{"type":"integer","minimum":0,"maximum":9007199254740991},"import_records":{"type":"integer","minimum":0,"maximum":9007199254740991}},"required":["job_postings","sec_filings","trade_court_cases","import_records"],"additionalProperties":false,"description":"Items per source for this company in `items`."},"total":{"type":"integer","minimum":0,"maximum":9007199254740991},"stale":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Of `total`, import records whose indexed shipment date is before the window: historical index evidence; financial and current-contract roles are not established."}},"required":["name","domain","accountId","matches","total","stale"],"additionalProperties":false},"description":"Every company in `items` once, most items first. It covers only the items returned; for a market-wide search, each source's `rollup` ranks the companies over every match it counted."},"next":{"type":"string","description":"What to do next, in one or two sentences."}},"required":["searchId","status","sources","items","companies","next"],"additionalProperties":false},"ListListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"origin":{"type":"string","enum":["saved","import","play"],"description":"`saved` for anything created here. `import` and `play` are app-created lists."},"memberCount":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Live, non-excluded members of both kinds."},"composition":{"type":"object","properties":{"accounts":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Whole-account entries."},"people":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Pinned people."}},"required":["accounts","people"],"additionalProperties":false,"description":"`memberCount`, split by kind."},"lastOutreachAt":{"description":"When the app last launched outreach from this list. Null until it has.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","origin","memberCount","composition","lastOutreachAt","createdAt","updatedAt"],"additionalProperties":false},"description":"Lists, most recently created first."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"CreateListDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"Human-readable name, shown in the Trayo app. Not unique — two lists may share a name, and the `id` is the identity."}},"required":["name"],"additionalProperties":false},"ListDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"origin":{"type":"string","enum":["saved","import","play"],"description":"`saved` for anything created here. `import` and `play` are app-created lists."},"memberCount":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Live, non-excluded members of both kinds."},"composition":{"type":"object","properties":{"accounts":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Whole-account entries."},"people":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Pinned people."}},"required":["accounts","people"],"additionalProperties":false,"description":"`memberCount`, split by kind."},"lastOutreachAt":{"description":"When the app last launched outreach from this list. Null until it has.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","origin","memberCount","composition","lastOutreachAt","createdAt","updatedAt"],"additionalProperties":false},"UpdateListDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"Human-readable name, shown in the Trayo app. Not unique — two lists may share a name, and the `id` is the identity."}},"required":["name"],"additionalProperties":false},"ListMemberListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"listId":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["person","account"]},"personId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Set on `kind: person`. Null on an account entry."},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null."},"via":{"type":"string","enum":["manual","account","signal","find","import"]},"eventId":{"anyOf":[{"type":"string"},{"type":"null"}]},"per":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"type":"boolean","description":"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":{"type":"string","format":"date-time"},"person":{"description":"On the members listing: who the person is. Null on an account entry.","anyOf":[{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's current account name, when they have one."}},"required":["fullName","title","company"],"additionalProperties":false},{"type":"null"}]},"account":{"description":"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.","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"website":{"type":"string","description":"The account's website URL."}},"required":["name","website"],"additionalProperties":false},{"type":"null"}]}},"required":["id","listId","kind","personId","accountId","via","eventId","per","excluded","createdAt"],"additionalProperties":false},"description":"Members, most recently added first."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"AddListMembersDto":{"type":"object","properties":{"members":{"minItems":1,"maxItems":200,"type":"array","items":{"type":"object","properties":{"personId":{"description":"Pin this person (a `GET /v1/people` id). Mutually exclusive with `accountId`.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"accountId":{"description":"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`.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"via":{"default":"manual","description":"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.","type":"string","enum":["manual","find","signal","import"]},"eventId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"}},"additionalProperties":false},"description":"1 to 200 members. Over the cap is an error, not a clamp."}},"required":["members"],"additionalProperties":false},"AddListMembersResultDto":{"type":"object","properties":{"added":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"listId":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["person","account"]},"personId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Set on `kind: person`. Null on an account entry."},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null."},"via":{"type":"string","enum":["manual","account","signal","find","import"]},"eventId":{"anyOf":[{"type":"string"},{"type":"null"}]},"per":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"type":"boolean","description":"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":{"type":"string","format":"date-time"},"person":{"description":"On the members listing: who the person is. Null on an account entry.","anyOf":[{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's current account name, when they have one."}},"required":["fullName","title","company"],"additionalProperties":false},{"type":"null"}]},"account":{"description":"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.","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"website":{"type":"string","description":"The account's website URL."}},"required":["name","website"],"additionalProperties":false},{"type":"null"}]}},"required":["id","listId","kind","personId","accountId","via","eventId","per","excluded","createdAt"],"additionalProperties":false},"description":"The rows that were added, in submission order."},"skipped":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"0-based position in the submitted `members`."},"reason":{"type":"string","description":"Why this row was not added."}},"required":["index","reason"],"additionalProperties":false},"description":"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."}},"required":["added","skipped"],"description":"**Partial success is the contract.** Adding a member who is already on the list is reported in `skipped`, not raised as an error, so re-sending the same batch is safe: nothing duplicates, and `added` tells you what was new.","additionalProperties":false},"ListMemberDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"listId":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["person","account"]},"personId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Set on `kind: person`. Null on an account entry."},"accountId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null."},"via":{"type":"string","enum":["manual","account","signal","find","import"]},"eventId":{"anyOf":[{"type":"string"},{"type":"null"}]},"per":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"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":{"type":"boolean","description":"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":{"type":"string","format":"date-time"},"person":{"description":"On the members listing: who the person is. Null on an account entry.","anyOf":[{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The person's current account name, when they have one."}},"required":["fullName","title","company"],"additionalProperties":false},{"type":"null"}]},"account":{"description":"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.","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"website":{"type":"string","description":"The account's website URL."}},"required":["name","website"],"additionalProperties":false},{"type":"null"}]}},"required":["id","listId","kind","personId","accountId","via","eventId","per","excluded","createdAt"],"additionalProperties":false},"WorkspaceDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The workspace your key acts in."},"name":{"type":"string","description":"The workspace name, as the Trayo app shows it. Read-only here."},"revision":{"type":"string","description":"Opaque version of the saved onboarding settings. Pass it as expectedRevision when replacing solution or persona lists; re-read on conflict."},"stakeholderCriteria":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A one- or two-sentence version of `solutions`. Null until set."},"solutionItems":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"url":{"anyOf":[{"type":"string"},{"type":"null"}]},"icon":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["name","description","url","icon"],"additionalProperties":false}},{"type":"null"}],"description":"Editable solution cards saved during onboarding. Null means this workspace only has prose; an empty array means no cards."},"stakeholderPersonas":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"seniority":{"type":"string","enum":["C-level","VP","Director","Manager"]},"buyingRole":{"anyOf":[{"type":"string","enum":["economic_buyer","decision_maker","champion","technical_evaluator"]},{"type":"null"}]},"rationale":{"type":"string"},"icon":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["title","seniority","buyingRole","rationale","icon"],"additionalProperties":false}},{"type":"null"}],"description":"Editable buyer and stakeholder personas saved during onboarding. Null means only prose is available."},"buyerProfile":{"type":"object","properties":{"isB2b":{"type":"boolean"},"notB2bReason":{"type":"string"},"sellerMode":{"type":"string","enum":["horizontal","vertical","local_smb","smb_horizontal","enterprise"]},"modeReason":{"type":"string"},"geography":{"type":"object","properties":{"scope":{"type":"string","enum":["local","regional","national","global"]},"countries":{"type":"array","items":{"type":"string"}},"regionLabel":{"type":"string"},"states":{"type":"array","items":{"type":"string"}},"cities":{"type":"array","items":{"type":"string"}},"radiusNote":{"type":"string"}},"required":["scope","countries","regionLabel","states","cities","radiusNote"],"additionalProperties":false},"sizeBand":{"type":"object","properties":{"min":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"max":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["min","max"],"additionalProperties":false},"sameIndustryBuyer":{"type":"boolean"},"rivalDefinition":{"type":"string"},"buyerThesis":{"type":"string"},"positiveSignals":{"type":"array","items":{"type":"string"}},"industries":{"type":"array","items":{"type":"string"}},"technologies":{"type":"array","items":{"type":"string"}},"competitorTerms":{"type":"array","items":{"type":"string"}},"categoryKeywords":{"type":"array","items":{"type":"string"}},"existingCustomers":{"type":"array","items":{"type":"string"}},"generationAngles":{"type":"array","items":{"type":"string"}}},"required":["isB2b","notB2bReason","sellerMode","modeReason","geography","sizeBand","sameIndustryBuyer","rivalDefinition","buyerThesis","positiveSignals","industries","technologies","competitorTerms","categoryKeywords","existingCustomers","generationAngles"],"additionalProperties":false,"description":"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."},"buyerProfileSource":{"type":"string","enum":["saved","partial","inferred","unreadable"],"description":"`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":{"anyOf":[{"type":"object","properties":{"domain":{"type":"string","description":"The website domain the brand belongs to, e.g. \"stripe.com\"."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The brand name."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A one- or two-sentence description of the company."},"colors":{"type":"array","items":{"type":"string"},"description":"Brand colors as lowercase `#rrggbb`, most prominent first."},"socials":{"type":"array","items":{"type":"object","properties":{"network":{"type":"string","description":"Network key, e.g. `linkedin`, `twitter`, `github`."},"url":{"type":"string","description":"Profile URL."}},"required":["network","url"],"additionalProperties":false},"description":"Social profiles found for the brand."},"logoUrl":{"type":"string","description":"Square logo image URL. Public, no key needed."},"brandmarkUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Wide logo lockup image URL (800px wide PNG). Public, no key needed. Null when none."},"bannerUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Social banner image URL (800px wide). Public, no key needed. Null when none."}},"required":["domain","name","description","colors","socials","logoUrl","brandmarkUrl","bannerUrl"],"additionalProperties":false,"description":"Your company brand, from your workspace website: name, description, colors, social profiles, and logo images you can embed. Null when no brand is known for the website."},{"type":"null"}]}},"required":["id","name","revision","stakeholderCriteria","solutions","solutionsBrief","solutionItems","stakeholderPersonas","buyerProfile","buyerProfileSource","brand"],"additionalProperties":false},"UpdateWorkspaceBodyDto":{"type":"object","properties":{"expectedRevision":{"description":"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.","type":"string","pattern":"^[a-f0-9]{64}$"},"stakeholderCriteria":{"description":"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.","anyOf":[{"type":"string","minLength":1,"maxLength":8000},{"type":"null"}]},"solutions":{"description":"What you sell, in prose. Send null to clear it.","anyOf":[{"type":"string","minLength":1,"maxLength":8000},{"type":"null"}]},"solutionsBrief":{"description":"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.","anyOf":[{"type":"string","minLength":1,"maxLength":8000},{"type":"null"}]},"solutionItems":{"description":"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.","maxItems":100,"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":"string","minLength":1,"maxLength":2000},"url":{"anyOf":[{"type":"string","maxLength":2000},{"type":"null"}]}},"required":["name","description"],"additionalProperties":false}},"stakeholderPersonas":{"description":"Replace the complete buyer-persona list. Requires expectedRevision. `stakeholderCriteria` is regenerated atomically. Send [] to clear the personas; icons are server-owned.","maxItems":100,"type":"array","items":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"seniority":{"type":"string","enum":["C-level","VP","Director","Manager"]},"buyingRole":{"anyOf":[{"type":"string","enum":["economic_buyer","decision_maker","champion","technical_evaluator"]},{"type":"null"}]},"rationale":{"type":"string","minLength":1,"maxLength":2000}},"required":["title","seniority","rationale"],"additionalProperties":false}},"buyerProfile":{"description":"Correct only the supplied ICP assumptions; nested geography and sizeBand are partial too. Requires an existing saved profile. Tenant-domain identity is never writable.","type":"object","properties":{"isB2b":{"type":"boolean"},"notB2bReason":{"type":"string","maxLength":2000},"sellerMode":{"type":"string","enum":["horizontal","vertical","local_smb","smb_horizontal","enterprise"]},"modeReason":{"type":"string","maxLength":2000},"geography":{"type":"object","properties":{"scope":{"type":"string","enum":["local","regional","national","global"]},"countries":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"regionLabel":{"type":"string","maxLength":2000},"states":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"cities":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"radiusNote":{"type":"string","maxLength":2000}},"additionalProperties":false},"sizeBand":{"type":"object","properties":{"min":{"type":"integer","minimum":0,"maximum":1000000000},"max":{"type":"integer","minimum":0,"maximum":1000000000}},"additionalProperties":false},"sameIndustryBuyer":{"type":"boolean"},"rivalDefinition":{"type":"string","maxLength":2000},"buyerThesis":{"type":"string","maxLength":2000},"positiveSignals":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"industries":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"technologies":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"competitorTerms":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"categoryKeywords":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"existingCustomers":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"generationAngles":{"maxItems":100,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}}},"additionalProperties":false}},"additionalProperties":false},"WhoamiDto":{"type":"object","properties":{"workspace":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The workspace this key acts in."},"name":{"type":"string"},"plan":{"type":"string","enum":["FREE","PRO","GROWTH","ENTERPRISE"]},"lifecycle":{"type":"string","enum":["trial_active","trial_grace","nurturing","purged","active"],"description":"`active` and `trial_active` can initiate metered work; the others are read-only. `canInitiate` is this, as a boolean."}},"required":["id","name","plan","lifecycle"],"additionalProperties":false},"actingAs":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","email","name"],"additionalProperties":false,"description":"The user your key acts as. Accounts and lists you create belong to this user and show up under them in the Trayo app."},"key":{"type":"object","properties":{"grantedScopes":{"type":"array","items":{"type":"string"},"description":"Persisted key grants; not plan eligibility."},"effectiveScopes":{"type":"array","items":{"type":"string"},"description":"Valid grants allowed by the owner’s current workspace permissions."},"expiresAt":{"description":"Key expiry; null means no scheduled expiry.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The label the key was issued with, for telling keys apart. `null` when it has none."}},"required":["grantedScopes","effectiveScopes","expiresAt","label"],"additionalProperties":false},"restrictions":{"type":"object","properties":{"dataAccess":{"type":"object","properties":{"workspaceId":{"type":"string","format":"uuid","description":"Workspace data stays within this tenant; endpoint-specific checks still apply."},"lists":{"type":"string","const":"created_by_key_owner","description":"List operations access only lists created by actingAs.userId."}},"required":["workspaceId","lists"],"additionalProperties":false},"rateLimits":{"type":"object","properties":{"defaultPerMinute":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"expensivePerMinute":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["defaultPerMinute","expensivePerMinute"],"additionalProperties":false,"description":"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\"]`."}},"required":["dataAccess","rateLimits"],"additionalProperties":false},"capabilities":{"type":"object","properties":{"emailLookup":{"type":"object","properties":{"scopeAuthorized":{"type":"boolean","description":"Whether this key and its owner currently authorize this lookup field."},"available":{"type":"boolean","description":"Passes scope, lifecycle and plan eligibility checks for a new lookup. Public REST/MCP usage quotas and the daily usage limit (`429 quota_daily_limit`) are disabled for now. Workspace request pacing, validation and per-network transport protection still apply."},"blockedBy":{"type":"array","items":{"type":"string","enum":["missing_key_scope","owner_permission_limit","lifecycle_blocked","plan_disabled","lookup_limit_reached"]},"description":"Which of these reasons block the field right now; an empty array means none of them do. Public REST/MCP lookup quotas and the daily usage limit (`429 quota_daily_limit`) are disabled for now. Workspace request pacing, row validation and per-network transport protection are checked when the lookup runs and do not appear in this list."}},"required":["scopeAuthorized","available","blockedBy"],"additionalProperties":false},"phoneLookup":{"type":"object","properties":{"scopeAuthorized":{"type":"boolean","description":"Whether this key and its owner currently authorize this lookup field."},"available":{"type":"boolean","description":"Passes scope, lifecycle and plan eligibility checks for a new lookup. Public REST/MCP usage quotas and the daily usage limit (`429 quota_daily_limit`) are disabled for now. Workspace request pacing, validation and per-network transport protection still apply."},"blockedBy":{"type":"array","items":{"type":"string","enum":["missing_key_scope","owner_permission_limit","lifecycle_blocked","plan_disabled","lookup_limit_reached"]},"description":"Which of these reasons block the field right now; an empty array means none of them do. Public REST/MCP lookup quotas and the daily usage limit (`429 quota_daily_limit`) are disabled for now. Workspace request pacing, row validation and per-network transport protection are checked when the lookup runs and do not appear in this list."}},"required":["scopeAuthorized","available","blockedBy"],"additionalProperties":false}},"required":["emailLookup","phoneLookup"],"additionalProperties":false},"canInitiate":{"type":"boolean","description":"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":{"type":"object","properties":{"email":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false},"phone":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false}},"required":["email","phone"],"additionalProperties":false,"description":"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."}},"required":["workspace","actingAs","key","restrictions","capabilities","canInitiate","lookups"],"additionalProperties":false},"StakeholderSearchBodyDto":{"type":"object","properties":{"company":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"accountId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"definition":{"description":"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.","type":"string","minLength":1,"maxLength":8000},"limit":{"description":"How many people to return, 5 by default, 10 at most. Fewer come back when fewer fit.","type":"integer","minimum":1,"maximum":10},"personas":{"description":"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: []`.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}}},"required":["company"],"additionalProperties":false},"StakeholderSearchResponseDto":{"type":"object","properties":{"company":{"type":"object","properties":{"companyId":{"type":"string","description":"Trayo's id for the company. Send it back as `companyId` to name this exact company again."},"name":{"type":"string","description":"The company as Trayo records it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's website domain as Trayo files it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo has it."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"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":{"type":"string","enum":["account_match","company_id","linkedin","website","redirect","name"],"description":"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.\n\nA 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":{"anyOf":[{"type":"string","enum":["exact","approximate"]},{"type":"null"}],"description":"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."}},"required":["companyId","name","website","linkedinUrl","accountId","resolvedBy","nameMatch"],"additionalProperties":false},"definition":{"type":"object","properties":{"text":{"type":"string","description":"The definition the search actually ran with."},"source":{"type":"string","enum":["request","workspace","default"],"description":"`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."}},"required":["text","source"],"additionalProperties":false},"candidates":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"array","items":{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"location":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string"},"title":{"type":"string"}},"required":["companyName","title"],"additionalProperties":false},"description":"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":{"type":"string","description":"Why this person fits the definition, in the selection's own words. On a near-miss: what their role covers and what it lacks."},"matchedPersona":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"`false` on every row of `people`. `true` on every row of `nearMisses`: the selection judged this person NOT to fit the definition."}},"required":["fullName","title","headline","location","linkedinUrl","photoUrl","otherCurrentEmployers","otherCurrentRoles","whyThisPerson","matchedPersona","belowThreshold"],"additionalProperties":false},"description":"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":{"type":"array","items":{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"location":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string"},"title":{"type":"string"}},"required":["companyName","title"],"additionalProperties":false},"description":"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":{"type":"string","description":"Why this person fits the definition, in the selection's own words. On a near-miss: what their role covers and what it lacks."},"matchedPersona":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"`false` on every row of `people`. `true` on every row of `nearMisses`: the selection judged this person NOT to fit the definition."}},"required":["fullName","title","headline","location","linkedinUrl","photoUrl","otherCurrentEmployers","otherCurrentRoles","whyThisPerson","matchedPersona","belowThreshold"],"additionalProperties":false},"description":"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":{"type":"array","items":{"type":"object","properties":{"persona":{"type":"string"},"matched":{"type":"boolean"},"candidates":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["persona","matched","candidates"],"additionalProperties":false},"description":"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\"). 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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What to do about an empty `people`, or about a persona nobody in `people` matched. Null when people were found and every persona is matched."}},"required":["company","definition","candidates","people","nearMisses","coverage","next"],"additionalProperties":false},"StakeholderBatchSearchBodyDto":{"type":"object","properties":{"companies":{"minItems":1,"maxItems":50,"type":"array","items":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"accountId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false},"description":"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."},"definition":{"description":"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.","type":"string","minLength":1,"maxLength":8000},"limit":{"description":"How many people to return, 5 by default, 10 at most. Fewer come back when fewer fit.","type":"integer","minimum":1,"maximum":10},"personas":{"description":"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: []`.","minItems":1,"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":200}}},"required":["companies"],"additionalProperties":false},"StakeholderBatchSearchResponseDto":{"type":"object","properties":{"definition":{"type":"object","properties":{"text":{"type":"string","description":"The definition the search actually ran with."},"source":{"type":"string","enum":["request","workspace","default"],"description":"`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."}},"required":["text","source"],"additionalProperties":false},"results":{"type":"array","items":{"type":"object","properties":{"company":{"type":"object","properties":{"companyId":{"type":"string","description":"Trayo's id for the company. Send it back as `companyId` to name this exact company again."},"name":{"type":"string","description":"The company as Trayo records it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's website domain as Trayo files it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo has it."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"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":{"type":"string","enum":["account_match","company_id","linkedin","website","redirect","name"],"description":"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.\n\nA 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":{"anyOf":[{"type":"string","enum":["exact","approximate"]},{"type":"null"}],"description":"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."}},"required":["companyId","name","website","linkedinUrl","accountId","resolvedBy","nameMatch"],"additionalProperties":false},"candidates":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"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":{"type":"array","items":{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"location":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string"},"title":{"type":"string"}},"required":["companyName","title"],"additionalProperties":false},"description":"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":{"type":"string","description":"Why this person fits the definition, in the selection's own words. On a near-miss: what their role covers and what it lacks."},"matchedPersona":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"`false` on every row of `people`. `true` on every row of `nearMisses`: the selection judged this person NOT to fit the definition."}},"required":["fullName","title","headline","location","linkedinUrl","photoUrl","otherCurrentEmployers","otherCurrentRoles","whyThisPerson","matchedPersona","belowThreshold"],"additionalProperties":false},"description":"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":{"type":"array","items":{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}]},"location":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string"},"title":{"type":"string"}},"required":["companyName","title"],"additionalProperties":false},"description":"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":{"type":"string","description":"Why this person fits the definition, in the selection's own words. On a near-miss: what their role covers and what it lacks."},"matchedPersona":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"type":"boolean","description":"`false` on every row of `people`. `true` on every row of `nearMisses`: the selection judged this person NOT to fit the definition."}},"required":["fullName","title","headline","location","linkedinUrl","photoUrl","otherCurrentEmployers","otherCurrentRoles","whyThisPerson","matchedPersona","belowThreshold"],"additionalProperties":false},"description":"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":{"type":"array","items":{"type":"object","properties":{"persona":{"type":"string"},"matched":{"type":"boolean"},"candidates":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["persona","matched","candidates"],"additionalProperties":false},"description":"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\"). 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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What to do about an empty `people`, or about a persona nobody in `people` matched. Null when people were found and every persona is matched."},"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Which entry of the submitted `companies` this result is for, 0-based."}},"required":["company","candidates","people","nearMisses","coverage","next","index"],"additionalProperties":false},"description":"One entry per company that was searched, in `index` order. A mixed batch never fails as a whole."},"errors":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Which entry of the submitted `companies` this failure is for, 0-based."},"code":{"type":"string","enum":["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","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"],"description":"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":{"type":"string","enum":["fix_input","retry_later","do_not_retry"],"description":"What to do about the failure, and the only field you need in order to decide\nwhether to send the request again.\n\n`fix_input` — the request is wrong; change it and retry.\n\n`retry_later` — the request is right, the system is not ready; retry it\nunchanged, with backoff and a FINITE number of attempts. On a 500\n(`internal_error`) this especially does not mean retry indefinitely: the\nfailure is not known to be transient, only that nothing about your request\nneeds changing, so give up after a few attempts rather than looping.\n\n`do_not_retry` — retrying will never help.\n\nFixed per `code`; see the table above. Always present, on every error,\nincluding any code added after this document — so a client that does not\nrecognise a `code` can still act correctly by reading this field alone."},"message":{"type":"string"},"param":{"description":"Names the offending field, e.g. `companies[2]`.","type":"string"}},"required":["index","code","retry","message"],"additionalProperties":false},"description":"One entry per company that could not be searched. It costs nothing; send it again on `retry_later`."}},"required":["definition","results","errors"],"additionalProperties":false},"CompanyResearchBodyDto":{"type":"object","properties":{"company":{"type":"object","properties":{"companyId":{"description":"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.","anyOf":[{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"type":"string","pattern":"^[1-9]\\d*$"}]},"accountId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"name":{"description":"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.","type":"string","minLength":1,"maxLength":200}},"additionalProperties":false}},"required":["company"],"additionalProperties":false},"CompanyResearchResponseDto":{"type":"object","properties":{"company":{"type":"object","properties":{"companyId":{"type":"string","description":"Trayo's id for the company. Send it back as `companyId` to name this exact company again."},"name":{"type":"string","description":"The company as Trayo records it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's website domain as Trayo files it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The company's LinkedIn page, when Trayo has it."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"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":{"type":"string","enum":["account_match","company_id","linkedin","website","redirect","name"],"description":"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.\n\nA 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":{"anyOf":[{"type":"string","enum":["exact","approximate"]},{"type":"null"}],"description":"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."}},"required":["companyId","name","website","linkedinUrl","accountId","resolvedBy","nameMatch"],"additionalProperties":false},"research":{"type":"object","properties":{"displayName":{"type":"string"},"oneLiner":{"type":"string","description":"One sentence on what the company does."},"industry":{"anyOf":[{"type":"string"},{"type":"null"}]},"hqLocation":{"anyOf":[{"type":"string"},{"type":"null"}]},"employeeCount":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"A count or a band, as Trayo states it."},"foundingYear":{"anyOf":[{"type":"string"},{"type":"null"}]},"whyNow":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"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":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Why this company is a target for what your workspace sells, inferred from its industry, size and tech stack against your `solutions`."}},"required":["displayName","oneLiner","industry","hqLocation","employeeCount","foundingYear","whyNow","fit"],"additionalProperties":false,"description":"The overview as structured fields. `reportMarkdown` is the same content rendered for display. Neither field carries source links."},"reportMarkdown":{"type":"string","description":"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":{"type":"string","format":"date-time","description":"When this overview was generated — always this call; nothing is stored."}},"required":["company","research","reportMarkdown","generatedAt"],"additionalProperties":false},"PersonResearchBodyDto":{"type":"object","properties":{"person":{"type":"object","properties":{"personId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048}},"additionalProperties":false},"grounded":{"description":"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.","type":"boolean"}},"required":["person"],"additionalProperties":false},"PersonResearchResponseDto":{"type":"object","properties":{"person":{"type":"object","properties":{"fullName":{"type":"string","description":"The person as Trayo records them."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their LinkedIn profile, when Trayo has it."},"photoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The website domain of the employer named in research.company, for displaying its logo. Null when Trayo has no matching employer domain."},"company":{"anyOf":[{"type":"object","properties":{"companyId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Trayo's id for this employer. Send it back as `company.companyId` (company research, stakeholder search, job-change search) to name this exact company. Null when Trayo holds no company record for the employer: `name` is then the free text from the profile, and looking it up by name will usually miss. Search for it with a find companies `query` instead."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The employer as the profile names it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The employer's website, when Trayo holds a record for it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The employer's LinkedIn page, when Trayo holds a record for it."}},"required":["companyId","name","website","linkedinUrl"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"The person this call was scoped to, when you sent one."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"That person's account in your workspace, when they have one."},"resolvedBy":{"type":"string","enum":["stored_match","linkedin"],"description":"`stored_match`: the workspace person's existing match. `linkedin`: the LinkedIn profile, looked up by its handle."}},"required":["fullName","linkedinUrl","photoUrl","location","companyDomain","company","personId","accountId","resolvedBy"],"additionalProperties":false},"grounded":{"type":"boolean","description":"Whether web-search grounding was used for this overview."},"research":{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}]},"careerProgression":{"type":"array","items":{"type":"string"},"description":"Notable roles, oldest first, each as `Role at Company (start – end)`."},"professionalInsights":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Their role and trajectory, tied to how what your workspace sells could be relevant."},"challenges":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"What they are likely up against. Inferred from role, seniority and industry when `grounded` is off; evidenced and cited when it is on."},"personalInsights":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Only ever filled when `grounded` is on; empty otherwise."},"companyIntel":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Timely, cited facts about their company a seller should know now. Only ever filled when `grounded` is on; empty otherwise."},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Same rule as `location`: sourced or withheld."}},"required":["fullName","title","company","careerProgression","professionalInsights","challenges","personalInsights","companyIntel","location","education"],"additionalProperties":false,"description":"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":{"type":"string","description":"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":{"type":"string","format":"date-time","description":"When this overview was generated — always this call; nothing is stored."}},"required":["person","grounded","research","reportMarkdown","generatedAt"],"additionalProperties":false},"PersonBatchResearchBodyDto":{"type":"object","properties":{"people":{"minItems":1,"maxItems":10,"type":"array","items":{"type":"object","properties":{"personId":{"description":"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.","type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048}},"additionalProperties":false},"description":"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."}},"required":["people"],"additionalProperties":false},"PersonBatchResearchResponseDto":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"person":{"type":"object","properties":{"fullName":{"type":"string","description":"The person as Trayo records them."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Their LinkedIn profile, when Trayo has it."},"photoUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"object","properties":{"city":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"City, e.g. `San Diego`. Null when the profile names only a region or metro area."},"state":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"State, province or region, e.g. `California`."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 3166-1 alpha-2 country code, e.g. `US`."},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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."}},"required":["city","state","country","raw"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The website domain of the employer named in research.company, for displaying its logo. Null when Trayo has no matching employer domain."},"company":{"anyOf":[{"type":"object","properties":{"companyId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Trayo's id for this employer. Send it back as `company.companyId` (company research, stakeholder search, job-change search) to name this exact company. Null when Trayo holds no company record for the employer: `name` is then the free text from the profile, and looking it up by name will usually miss. Search for it with a find companies `query` instead."},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The employer as the profile names it."},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The employer's website, when Trayo holds a record for it."},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The employer's LinkedIn page, when Trayo holds a record for it."}},"required":["companyId","name","website","linkedinUrl"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"The person this call was scoped to, when you sent one."},"accountId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"That person's account in your workspace, when they have one."},"resolvedBy":{"type":"string","enum":["stored_match","linkedin"],"description":"`stored_match`: the workspace person's existing match. `linkedin`: the LinkedIn profile, looked up by its handle."}},"required":["fullName","linkedinUrl","photoUrl","location","companyDomain","company","personId","accountId","resolvedBy"],"additionalProperties":false},"grounded":{"type":"boolean","description":"Whether web-search grounding was used for this overview."},"research":{"type":"object","properties":{"fullName":{"type":"string"},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"company":{"anyOf":[{"type":"string"},{"type":"null"}]},"careerProgression":{"type":"array","items":{"type":"string"},"description":"Notable roles, oldest first, each as `Role at Company (start – end)`."},"professionalInsights":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Their role and trajectory, tied to how what your workspace sells could be relevant."},"challenges":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"What they are likely up against. Inferred from role, seniority and industry when `grounded` is off; evidenced and cited when it is on."},"personalInsights":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Only ever filled when `grounded` is on; empty otherwise."},"companyIntel":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"A short categorisation of the point (\"Acquisition\", \"Analyst Recognition\", \"Enterprise Scale\") — what you can group or scan by. Not a restatement of `text`."},"text":{"type":"string","description":"The point itself."}},"required":["label","text"],"additionalProperties":false},"description":"Timely, cited facts about their company a seller should know now. Only ever filled when `grounded` is on; empty otherwise."},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Same rule as `location`: sourced or withheld."}},"required":["fullName","title","company","careerProgression","professionalInsights","challenges","personalInsights","companyIntel","location","education"],"additionalProperties":false,"description":"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":{"type":"string","description":"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":{"type":"string","format":"date-time","description":"When this overview was generated — always this call; nothing is stored."},"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Which entry of the submitted `people` this overview is for, 0-based."}},"required":["person","grounded","research","reportMarkdown","generatedAt","index"],"additionalProperties":false},"description":"One overview per resolved person. A mixed batch never fails as a whole."},"errors":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Which entry of the submitted `people` this failure is for, 0-based."},"code":{"type":"string","enum":["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","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"],"description":"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":{"type":"string","enum":["fix_input","retry_later","do_not_retry"],"description":"What to do about the failure, and the only field you need in order to decide\nwhether to send the request again.\n\n`fix_input` — the request is wrong; change it and retry.\n\n`retry_later` — the request is right, the system is not ready; retry it\nunchanged, with backoff and a FINITE number of attempts. On a 500\n(`internal_error`) this especially does not mean retry indefinitely: the\nfailure is not known to be transient, only that nothing about your request\nneeds changing, so give up after a few attempts rather than looping.\n\n`do_not_retry` — retrying will never help.\n\nFixed per `code`; see the table above. Always present, on every error,\nincluding any code added after this document — so a client that does not\nrecognise a `code` can still act correctly by reading this field alone."},"message":{"type":"string"},"param":{"description":"Names the offending field, e.g. `people[2].personId`.","type":"string"}},"required":["index","code","retry","message"],"additionalProperties":false},"description":"One entry per person this call could not produce an overview for."}},"required":["results","errors"],"additionalProperties":false},"SourceReadBodyDto":{"type":"object","properties":{"sourceRef":{"anyOf":[{"type":"object","properties":{"searchId":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"url":{"type":"string","minLength":1,"maxLength":2048}},"required":["searchId","source","url"],"additionalProperties":false},{"type":"object","properties":{"eventId":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"}},"required":["eventId"],"additionalProperties":false}],"description":"An exact URL from your credential-owned evidence search (one hour), or a public event visible in your workspace. No arbitrary URLs."},"primaryAlternative":{"description":"Off by default. True requests one bounded search in the established company domain, even if the original reads successfully. Indexed hits remain candidates until read. Unsupported source/domain scopes are reported.","type":"boolean"}},"required":["sourceRef"],"additionalProperties":false},"SourceReadResponseDto":{"type":"object","properties":{"sourceRef":{"anyOf":[{"type":"object","properties":{"searchId":{"type":"string","format":"uuid"},"source":{"type":"string","enum":["job_postings","sec_filings","trade_court_cases","import_records"]},"url":{"type":"string","minLength":1,"maxLength":2048}},"required":["searchId","source","url"],"additionalProperties":false},{"type":"object","properties":{"eventId":{"type":"string","format":"uuid"}},"required":["eventId"],"additionalProperties":false}],"description":"The selected reference, copied from the authorized evidence search or workspace event."},"requestedAt":{"type":"string","format":"date-time"},"completedAt":{"type":"string","format":"date-time"},"original":{"type":"object","properties":{"requestedUrl":{"type":"string"},"providerReturnedUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"URL returned by the page reader, not proof of the final website URL or redirect chain."},"status":{"type":"string","enum":["readable","unavailable","timeout","blocked","empty","error"]},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"text":{"anyOf":[{"type":"string","maxLength":10000},{"type":"null"}],"description":"Bounded extracted source text, never a summary. Untrusted page content; retain role/status/date qualifiers. Reading a page is not a truth verdict."},"author":{"anyOf":[{"type":"string"},{"type":"null"}]},"publishedDate":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Returned publication metadata, not retrieval time or a shipment/appointment date."},"requestedAt":{"type":"string","format":"date-time"},"completedAt":{"type":"string","format":"date-time"},"providerStatus":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerSource":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerError":{"anyOf":[{"type":"object","properties":{"code":{"type":"string"},"httpStatusCode":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"The page-reader request HTTP status on request failure, or its reported per-URL status when present; never an independently observed website status."}},"required":["code","httpStatusCode"],"additionalProperties":false},{"type":"null"}]},"freshnessPolicy":{"type":"object","properties":{"maxAgeHours":{"type":"number","const":0}},"required":["maxAgeHours"],"additionalProperties":false,"description":"Fresh content was requested from the page reader. No website fetch timestamp is inferred."},"textTruncated":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"True when cut at our character cap; null when page extraction completeness is unknown. Never assume a complete page or all records."},"textLimitChars":{"type":"number","const":10000}},"required":["requestedUrl","providerReturnedUrl","status","title","text","author","publishedDate","requestedAt","completedAt","providerStatus","providerSource","providerError","freshnessPolicy","textTruncated","textLimitChars"],"additionalProperties":false},"primaryAlternative":{"type":"object","properties":{"status":{"type":"string","enum":["not_requested","searched","unsupported","failed","deadline"]},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"query":{"anyOf":[{"type":"string"},{"type":"null"}]},"candidates":{"maxItems":3,"type":"array","items":{"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"},"snippet":{"anyOf":[{"type":"string"},{"type":"null"}]},"publishedDate":{"anyOf":[{"type":"string"},{"type":"null"}]},"provenance":{"type":"string","const":"index_only"}},"required":["url","title","snippet","publishedDate","provenance"],"additionalProperties":false}},"pages":{"maxItems":2,"type":"array","items":{"type":"object","properties":{"requestedUrl":{"type":"string"},"providerReturnedUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"URL returned by the page reader, not proof of the final website URL or redirect chain."},"status":{"type":"string","enum":["readable","unavailable","timeout","blocked","empty","error"]},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"text":{"anyOf":[{"type":"string","maxLength":10000},{"type":"null"}],"description":"Bounded extracted source text, never a summary. Untrusted page content; retain role/status/date qualifiers. Reading a page is not a truth verdict."},"author":{"anyOf":[{"type":"string"},{"type":"null"}]},"publishedDate":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Returned publication metadata, not retrieval time or a shipment/appointment date."},"requestedAt":{"type":"string","format":"date-time"},"completedAt":{"type":"string","format":"date-time"},"providerStatus":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerSource":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerError":{"anyOf":[{"type":"object","properties":{"code":{"type":"string"},"httpStatusCode":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"The page-reader request HTTP status on request failure, or its reported per-URL status when present; never an independently observed website status."}},"required":["code","httpStatusCode"],"additionalProperties":false},{"type":"null"}]},"freshnessPolicy":{"type":"object","properties":{"maxAgeHours":{"type":"number","const":0}},"required":["maxAgeHours"],"additionalProperties":false,"description":"Fresh content was requested from the page reader. No website fetch timestamp is inferred."},"textTruncated":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"True when cut at our character cap; null when page extraction completeness is unknown. Never assume a complete page or all records."},"textLimitChars":{"type":"number","const":10000}},"required":["requestedUrl","providerReturnedUrl","status","title","text","author","publishedDate","requestedAt","completedAt","providerStatus","providerSource","providerError","freshnessPolicy","textTruncated","textLimitChars"],"additionalProperties":false}},"limits":{"type":"array","items":{"type":"string"}}},"required":["status","domain","query","candidates","pages","limits"],"additionalProperties":false},"limits":{"type":"array","items":{"type":"string"}}},"required":["sourceRef","requestedAt","completedAt","original","primaryAlternative","limits"],"additionalProperties":false},"StakeholderSourceReadBodyDto":{"type":"object","properties":{"sourceRef":{"type":"object","properties":{"stakeholderSearchId":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid"},"companyIndex":{"type":"integer","minimum":0,"maximum":49,"description":"Original company index in the submitted stakeholder batch, not its position in results."},"personIndex":{"type":"integer","minimum":0,"maximum":9,"description":"Index in that company result's selected people array."}},"required":["stakeholderSearchId","companyIndex","personIndex"],"additionalProperties":false,"description":"Copy a person's sourceRef from a completed stakeholder batch. Readable for one hour, only with the credential that started the search. No arbitrary URLs."},"primaryAlternative":{"description":"Off by default. True requests one bounded search in the established company domain, even if the original reads successfully. Indexed hits remain candidates until read. Unsupported source/domain scopes are reported.","type":"boolean"}},"required":["sourceRef"],"additionalProperties":false},"StakeholderSourceReadResponseDto":{"type":"object","properties":{"requestedAt":{"type":"string","format":"date-time"},"completedAt":{"type":"string","format":"date-time"},"limits":{"type":"array","items":{"type":"string"}},"sourceRef":{"type":"object","properties":{"stakeholderSearchId":{"type":"string","format":"uuid"},"companyIndex":{"type":"integer","minimum":0,"maximum":49,"description":"Original company index in the submitted stakeholder batch, not its position in results."},"personIndex":{"type":"integer","minimum":0,"maximum":9,"description":"Index in that company result's selected people array."}},"required":["stakeholderSearchId","companyIndex","personIndex"],"additionalProperties":false,"description":"Reference to this selected person's stored profile URL for optional source reading, when available."},"pages":{"minItems":1,"maxItems":3,"type":"array","items":{"type":"object","properties":{"requestedUrl":{"type":"string"},"providerReturnedUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"URL returned by the page reader, not proof of the final website URL or redirect chain."},"status":{"type":"string","enum":["readable","unavailable","timeout","blocked","empty","error"]},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"text":{"anyOf":[{"type":"string","maxLength":10000},{"type":"null"}],"description":"Bounded extracted source text, never a summary. Untrusted page content; retain role/status/date qualifiers. Reading a page is not a truth verdict."},"author":{"anyOf":[{"type":"string"},{"type":"null"}]},"publishedDate":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Returned publication metadata, not retrieval time or a shipment/appointment date."},"requestedAt":{"type":"string","format":"date-time"},"completedAt":{"type":"string","format":"date-time"},"providerStatus":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerSource":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerError":{"anyOf":[{"type":"object","properties":{"code":{"type":"string"},"httpStatusCode":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"The page-reader request HTTP status on request failure, or its reported per-URL status when present; never an independently observed website status."}},"required":["code","httpStatusCode"],"additionalProperties":false},{"type":"null"}]},"freshnessPolicy":{"type":"object","properties":{"maxAgeHours":{"type":"number","const":0}},"required":["maxAgeHours"],"additionalProperties":false,"description":"Fresh content was requested from the page reader. No website fetch timestamp is inferred."},"textTruncated":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"True when cut at our character cap; null when page extraction completeness is unknown. Never assume a complete page or all records."},"textLimitChars":{"type":"number","const":10000},"role":{"type":"string","enum":["original","primary_alternative"]}},"required":["requestedUrl","providerReturnedUrl","status","title","text","author","publishedDate","requestedAt","completedAt","providerStatus","providerSource","providerError","freshnessPolicy","textTruncated","textLimitChars","role"],"additionalProperties":false}},"primaryAlternative":{"type":"object","properties":{"status":{"type":"string","enum":["not_requested","searched","unsupported","failed","deadline"]},"domain":{"anyOf":[{"type":"string"},{"type":"null"}]},"query":{"anyOf":[{"type":"string"},{"type":"null"}]},"candidates":{"maxItems":3,"type":"array","items":{"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"},"snippet":{"anyOf":[{"type":"string"},{"type":"null"}]},"publishedDate":{"anyOf":[{"type":"string"},{"type":"null"}]},"provenance":{"type":"string","const":"index_only"}},"required":["url","title","snippet","publishedDate","provenance"],"additionalProperties":false}},"limits":{"type":"array","items":{"type":"string"}}},"required":["status","domain","query","candidates","limits"],"additionalProperties":false}},"required":["requestedAt","completedAt","limits","sourceRef","pages","primaryAlternative"],"additionalProperties":false},"ContactLookupBodyDto":{"type":"object","properties":{"fullName":{"type":"string","minLength":1,"maxLength":200,"description":"The person's full name, as it reads on their profile."},"linkedinUrl":{"description":"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.","type":"string","minLength":1,"maxLength":2048},"company":{"description":"Where the person works. Include it when known to help identify the person.","type":"object","properties":{"name":{"description":"The employer's name. Used alongside the website, never instead of it.","type":"string","minLength":1,"maxLength":200},"website":{"description":"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.","type":"string","minLength":3,"maxLength":2048}},"additionalProperties":false},"fields":{"description":"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.","minItems":1,"maxItems":2,"type":"array","items":{"type":"string","enum":["email","phone"]}}},"required":["fullName"],"additionalProperties":false},"ContactLookupResultDto":{"type":"object","properties":{"person":{"type":"object","properties":{"fullName":{"type":"string"},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The profile the lookup used, canonicalized from whatever form you sent."},"company":{"type":"object","properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"website":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"The domain the lookup used, taken from the website you sent."}},"required":["name","website"],"additionalProperties":false}},"required":["fullName","linkedinUrl","company"],"additionalProperties":false,"description":"The identity as the lookup understood it. Check it when the answer surprises you."},"email":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What was found, or null."},"status":{"type":"string","enum":["found","not_found","pending","not_attempted","not_requested","failed","unconfirmed"],"description":"`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":{"description":"When the lookup ran. For a saved result, this is the time of the original lookup.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"source":{"anyOf":[{"type":"string","enum":["lookup","saved"]},{"type":"null"}],"description":"`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":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`."},"error":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"An `error.code` from the table below, scoped to this field."},"retry":{"type":"string","enum":["fix_input","retry_later","do_not_retry"],"description":"What to do about it, as on an error response."},"message":{"type":"string"}},"required":["code","retry","message"],"additionalProperties":false},{"type":"null"}],"description":"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":{"anyOf":[{"type":"string","enum":["match","conflict","unknown"]},{"type":"null"}],"description":"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."}},"required":["value","status","foundAt","source","lookupId","error","identityConfidence"],"additionalProperties":false},"phone":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"What was found, or null."},"status":{"type":"string","enum":["found","not_found","pending","not_attempted","not_requested","failed"],"description":"`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":{"description":"When the lookup ran. For a saved result, this is the time of the original lookup.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"source":{"anyOf":[{"type":"string","enum":["lookup","saved"]},{"type":"null"}],"description":"`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":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`."},"error":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"An `error.code` from the table below, scoped to this field."},"retry":{"type":"string","enum":["fix_input","retry_later","do_not_retry"],"description":"What to do about it, as on an error response."},"message":{"type":"string"}},"required":["code","retry","message"],"additionalProperties":false},{"type":"null"}],"description":"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."}},"required":["value","status","foundAt","source","lookupId","error"],"additionalProperties":false,"description":"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`."},"quota":{"type":"object","properties":{"email":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false},"phone":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the plan allows this kind of lookup at all. `false` means every lookup is refused."},"limit":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply."},"used":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`."},"remaining":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}],"description":"Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`."},"period":{"type":"string","const":"lifetime","description":"The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped."}},"required":["enabled","limit","used","remaining","period"],"additionalProperties":false}},"required":["email","phone"],"additionalProperties":false,"description":"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."}},"required":["person","email","phone"],"additionalProperties":false},"ContactLookupStatusDto":{"type":"object","properties":{"lookupId":{"type":"string","format":"uuid"},"field":{"type":"string","enum":["email","phone"],"description":"Which value this lookup is for."},"value":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"type":"string","enum":["found","not_found","pending","failed"],"description":"`pending` means still running — poll every 10 to 15 seconds. `failed` is terminal: stop polling this ID."},"startedAt":{"description":"When the lookup was accepted.","type":"string","format":"date-time"},"foundAt":{"description":"When it finished. Null while `pending`.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"error":{"description":"Present on failed: stop polling this ID; a new POST may be tried later and is billed normally.","type":"object","properties":{"code":{"type":"string","enum":["phone_lookup_failed","phone_lookup_expired"]},"retry":{"type":"string","enum":["fix_input","retry_later","do_not_retry"]},"message":{"type":"string"}},"required":["code","retry","message"],"additionalProperties":false}},"required":["lookupId","field","value","status","startedAt","foundAt"],"additionalProperties":false},"CreateAppDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"description":"Display name, 1-80 characters."},"slug":{"description":"Hostname label, 3-40 lowercase letters, digits and single hyphens, starting with a letter (never `draft`). Becomes `https://<slug>.<workspace>.<apps domain>`, unique within this workspace, and can never change or be reused here. Omit to derive one from `name`. Re-sending the same slug returns the existing app.","type":"string"},"externalAccess":{"type":"string","enum":["none","password","public"],"description":"The outside link. `none` (default): only people the app is shared with inside Trayo. `password`: also anyone with the app password, which a human sets on the setup page. `public`: anyone on the internet; available only to workspaces enabled for it."},"accessMode":{"type":"string","enum":["tenant","password","public"],"description":"DEPRECATED: use `externalAccess` (`tenant` = `none`). The outside link of the app: `tenant`: no outside link, only people it is shared with in this workspace. `password`: anyone with the app password, which a human sets on the setup page. `public`: anyone on the internet; available only to workspaces enabled for it."}},"required":["name"],"additionalProperties":false},"AppDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":"string"},"tenantLabel":{"type":"string","description":"This workspace's apps subdomain (the middle label of `url`)."},"url":{"type":"string","description":"Published app URL. Shows \"not published yet\" until the first publish."},"draftUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Draft URL: what was last deployed to Draft. Only workspace editors can open it; null for everyone else."},"visibility":{"type":"string","enum":["private","people","workspace"],"description":"Who in this workspace can open it."},"externalAccess":{"type":"string","enum":["none","password","public"],"description":"The outside link: none, password or public."},"accessMode":{"type":"string","enum":["tenant","password","public"],"description":"DEPRECATED: derived from `externalAccess` (`none` = `tenant`)."},"owner":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","email","name"],"additionalProperties":false},{"type":"null"}],"description":"Who owns the app (shares, deletes, transfers it)."},"role":{"anyOf":[{"type":"string","enum":["admin","owner","editor","viewer"]},{"type":"null"}],"description":"YOUR role on this app: admin (workspace admin), owner, editor or viewer."},"everPublished":{"type":"boolean","description":"The app has been published at least once."},"status":{"type":"string","enum":["active","disabled","suspended","deleting","deleted"]},"suspension":{"anyOf":[{"type":"object","properties":{"reason":{"type":"string","description":"e.g. `budget:requests_per_day`."},"since":{"type":"string","format":"date-time"},"autoLiftAt":{"description":"When a daily-budget suspension lifts by itself.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["reason","since","autoLiftAt"],"additionalProperties":false},{"type":"null"}],"description":"Set while Trayo safety limits pause the app (`status: \"suspended\"`)."},"passwordSet":{"type":"boolean","description":"Whether a password exists. The password itself is never returned."},"outsideAccess":{"anyOf":[{"type":"object","properties":{"acknowledgedById":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Who acknowledged; null when nobody has, or for the record kept from before acknowledgements existed."},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"capabilities":{"type":"array","items":{"type":"string"},"description":"Capabilities anonymous visitors of the link may use."},"writeCapabilities":{"type":"array","items":{"type":"string"},"description":"Write capabilities kept for them with their own tick."},"needsAcknowledgement":{"type":"array","items":{"type":"string"},"description":"Approved capabilities anonymous visitors cannot use until someone acknowledges them in Settings."}},"required":["acknowledgedById","acknowledgedAt","capabilities","writeCapabilities","needsAcknowledgement"],"additionalProperties":false},{"type":"null"}],"description":"Password or public apps only (null otherwise): which approved capabilities anonymous visitors of the link may use with the owner's connections. People signed in to Trayo always use their own access."},"secretNames":{"type":"array","items":{"type":"string"},"description":"Names of secrets that are set. Values are never returned."},"secretsNeedingReentry":{"type":"array","items":{"type":"string"},"description":"Secrets set before Trayo stored them encrypted: a human must set them again for the Draft to get them."},"capabilities":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["requested","approved","revoked"]},"killed":{"type":"boolean"},"pendingChange":{"type":"boolean","description":"The app now asks for more than was approved."},"why":{"description":"What the app says it uses this capability for.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","status","killed","pendingChange"],"additionalProperties":false},"description":"Platform capabilities the app requested and their approval state. Admins approve them in Settings."},"schedules":{"type":"object","properties":{"count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Active schedules of the published app."},"nextRunAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["count","nextRunAt"],"additionalProperties":false},"published":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"The deploy the published app serves, if any."},"draft":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"The deploy the Draft serves, if any (editors only; null for others)."},"activeDeployment":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"Same as `published` (kept for older clients)."},"latestDeployment":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"The newest deploy in any state. Poll this."},"setupUrl":{"type":"string","description":"Dashboard page where a human sets the password and secrets and approves capabilities. Send this link to the user."},"purgeAt":{"description":"Set once the app is being deleted: when its data (database, file storage, archives) is permanently purged. Null for a live app.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","slug","tenantLabel","url","draftUrl","visibility","externalAccess","accessMode","owner","role","everPublished","status","suspension","passwordSet","outsideAccess","secretNames","secretsNeedingReentry","capabilities","schedules","published","draft","activeDeployment","latestDeployment","setupUrl","purgeAt","createdAt","updatedAt"],"additionalProperties":false},"AppListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":"string"},"tenantLabel":{"type":"string","description":"This workspace's apps subdomain (the middle label of `url`)."},"url":{"type":"string","description":"Published app URL. Shows \"not published yet\" until the first publish."},"draftUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Draft URL: what was last deployed to Draft. Only workspace editors can open it; null for everyone else."},"visibility":{"type":"string","enum":["private","people","workspace"],"description":"Who in this workspace can open it."},"externalAccess":{"type":"string","enum":["none","password","public"],"description":"The outside link: none, password or public."},"accessMode":{"type":"string","enum":["tenant","password","public"],"description":"DEPRECATED: derived from `externalAccess` (`none` = `tenant`)."},"owner":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","email","name"],"additionalProperties":false},{"type":"null"}],"description":"Who owns the app (shares, deletes, transfers it)."},"role":{"anyOf":[{"type":"string","enum":["admin","owner","editor","viewer"]},{"type":"null"}],"description":"YOUR role on this app: admin (workspace admin), owner, editor or viewer."},"everPublished":{"type":"boolean","description":"The app has been published at least once."},"status":{"type":"string","enum":["active","disabled","suspended","deleting","deleted"]},"suspension":{"anyOf":[{"type":"object","properties":{"reason":{"type":"string","description":"e.g. `budget:requests_per_day`."},"since":{"type":"string","format":"date-time"},"autoLiftAt":{"description":"When a daily-budget suspension lifts by itself.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["reason","since","autoLiftAt"],"additionalProperties":false},{"type":"null"}],"description":"Set while Trayo safety limits pause the app (`status: \"suspended\"`)."},"passwordSet":{"type":"boolean","description":"Whether a password exists. The password itself is never returned."},"outsideAccess":{"anyOf":[{"type":"object","properties":{"acknowledgedById":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Who acknowledged; null when nobody has, or for the record kept from before acknowledgements existed."},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"capabilities":{"type":"array","items":{"type":"string"},"description":"Capabilities anonymous visitors of the link may use."},"writeCapabilities":{"type":"array","items":{"type":"string"},"description":"Write capabilities kept for them with their own tick."},"needsAcknowledgement":{"type":"array","items":{"type":"string"},"description":"Approved capabilities anonymous visitors cannot use until someone acknowledges them in Settings."}},"required":["acknowledgedById","acknowledgedAt","capabilities","writeCapabilities","needsAcknowledgement"],"additionalProperties":false},{"type":"null"}],"description":"Password or public apps only (null otherwise): which approved capabilities anonymous visitors of the link may use with the owner's connections. People signed in to Trayo always use their own access."},"secretNames":{"type":"array","items":{"type":"string"},"description":"Names of secrets that are set. Values are never returned."},"secretsNeedingReentry":{"type":"array","items":{"type":"string"},"description":"Secrets set before Trayo stored them encrypted: a human must set them again for the Draft to get them."},"capabilities":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["requested","approved","revoked"]},"killed":{"type":"boolean"},"pendingChange":{"type":"boolean","description":"The app now asks for more than was approved."},"why":{"description":"What the app says it uses this capability for.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","status","killed","pendingChange"],"additionalProperties":false},"description":"Platform capabilities the app requested and their approval state. Admins approve them in Settings."},"schedules":{"type":"object","properties":{"count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Active schedules of the published app."},"nextRunAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]}},"required":["count","nextRunAt"],"additionalProperties":false},"published":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"The deploy the published app serves, if any."},"draft":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"The deploy the Draft serves, if any (editors only; null for others)."},"activeDeployment":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"Same as `published` (kept for older clients)."},"latestDeployment":{"anyOf":[{"type":"object","properties":{"previewDataLeftBehind":{"description":"The committed first publish left some Draft data behind; the Draft copy was preserved.","type":"boolean"},"id":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Per-app deploy number, 1, 2, 3..."},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"isDraft":{"type":"boolean","description":"This deploy is what the Draft serves."},"isPublished":{"type":"boolean","description":"This deploy is what the published app serves."},"previewUsesSampleData":{"description":"The preview uses an isolated sample of saved data, never the live data store.","type":"boolean"},"previewReadyAt":{"description":"When the platform confirmed this Draft serves and the app assistant's own checks of it passed; null until then.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"publishedAt":{"description":"Last time this deploy was published.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable failure code, e.g. `archive_rejected`, `worker_upload_failed`."},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]}},"required":["id","number","status","isDraft","isPublished","publishedAt","createdAt","finishedAt","failure"],"additionalProperties":false},{"type":"null"}],"description":"The newest deploy in any state. Poll this."},"setupUrl":{"type":"string","description":"Dashboard page where a human sets the password and secrets and approves capabilities. Send this link to the user."},"purgeAt":{"description":"Set once the app is being deleted: when its data (database, file storage, archives) is permanently purged. Null for a live app.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","slug","tenantLabel","url","draftUrl","visibility","externalAccess","accessMode","owner","role","everPublished","status","suspension","passwordSet","outsideAccess","secretNames","secretsNeedingReentry","capabilities","schedules","published","draft","activeDeployment","latestDeployment","setupUrl","purgeAt","createdAt","updatedAt"],"additionalProperties":false},"description":"Apps, most recently created first. Deleted apps are omitted."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"UpdateAppDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"description":"Display name, 1-80 characters."},"visibility":{"type":"string","enum":["private","people","workspace"],"description":"Who in this workspace can open the app: `private` (only its owner), `people` (the people it is shared with) or `workspace` (every member). Workspace admins always can. New apps are private. Only the owner or an admin can change it; adding people is done in Trayo."},"externalAccess":{"type":"string","enum":["none","password","public"],"description":"The outside link. `none` (default): only people the app is shared with inside Trayo. `password`: also anyone with the app password, which a human sets on the setup page. `public`: anyone on the internet; available only to workspaces enabled for it."},"accessMode":{"type":"string","enum":["tenant","password","public"],"description":"DEPRECATED: use `externalAccess` (`tenant` = `none`). The outside link of the app: `tenant`: no outside link, only people it is shared with in this workspace. `password`: anyone with the app password, which a human sets on the setup page. `public`: anyone on the internet; available only to workspaces enabled for it."}},"additionalProperties":false},"PrepareUploadDto":{"type":"object","properties":{"sizeBytes":{"type":"integer","minimum":1,"maximum":9007199254740991,"description":"Exact byte size of the .tar.gz you will upload."},"sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Lowercase hex SHA-256 of the exact .tar.gz bytes. Trayo verifies it before deploying."}},"required":["sizeBytes","sha256"],"additionalProperties":false},"PreparedUploadDto":{"type":"object","properties":{"deploymentId":{"type":"string","format":"uuid"},"deploymentNumber":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"upload":{"type":"object","properties":{"method":{"type":"string","enum":["PUT","POST"],"description":"Use exactly this method."},"url":{"type":"string","description":"Short-lived upload URL. Send the archive bytes as the request body."},"headers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"},"description":"Send exactly these headers."},"expiresAt":{"type":"string","format":"date-time"},"maxBytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["method","url","headers","expiresAt","maxBytes"],"additionalProperties":false},"archiveFormat":{"type":"object","properties":{"version":{"type":"number","const":1},"summary":{"type":"string"},"docsUrl":{"type":"string"}},"required":["version","summary","docsUrl"],"additionalProperties":false}},"required":["deploymentId","deploymentNumber","upload","archiveFormat"],"additionalProperties":false},"FinalizeDeploymentDto":{"type":"object","properties":{"deploymentId":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid","description":"The `deploymentId` returned when you prepared the upload."},"target":{"type":"string","enum":["draft","published"],"description":"`draft` (default): install on the app's Draft, which only workspace editors can open at `draftUrl`. `published`: install and publish at once (only when the user asked for it)."}},"required":["deploymentId"],"additionalProperties":false},"DeploymentDto":{"type":"object","properties":{"previewDataLeftBehind":{"type":"boolean","description":"The committed first publish left some Draft data behind; the Draft copy was preserved."},"id":{"type":"string","format":"uuid"},"appId":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"target":{"type":"string","enum":["draft","published"],"description":"What the deploy was asked to install."},"isDraft":{"type":"boolean"},"isPublished":{"type":"boolean"},"publishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"archive":{"anyOf":[{"type":"object","properties":{"sha256":{"type":"string"},"sizeBytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["sha256","sizeBytes"],"additionalProperties":false},{"type":"null"}]},"manifest":{"description":"The normalized archive manifest.","anyOf":[{"oneOf":[{"type":"object","properties":{"schemaVersion":{"type":"number","const":1},"worker":{"type":"object","properties":{"mainModule":{"type":"string","minLength":1,"maxLength":255},"compatibilityDate":{"type":"string"},"compatibilityFlags":{"default":[],"maxItems":16,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}}},"required":["mainModule","compatibilityDate","compatibilityFlags"],"additionalProperties":false},"assets":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"default":null,"anyOf":[{"type":"string"},{"type":"null"}]},"htmlHandling":{"default":"auto-trailing-slash","type":"string","enum":["auto-trailing-slash","force-trailing-slash","drop-trailing-slash","none"]},"notFoundHandling":{"default":"none","type":"string","enum":["none","404-page","single-page-application"]},"runWorkerFirst":{"default":false,"anyOf":[{"type":"boolean"},{"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":256}}]}},"required":["binding","htmlHandling","notFoundHandling","runWorkerFirst"],"additionalProperties":false},{"type":"null"}]},"d1":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"r2":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"vars":{"default":{},"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"secrets":{"default":[],"maxItems":32,"type":"array","items":{"type":"string"}},"build":{"type":"object","properties":{"tool":{"type":"string","minLength":1,"maxLength":64},"toolVersion":{"type":"string","minLength":1,"maxLength":64},"wranglerVersion":{"type":"string","minLength":1,"maxLength":64},"nodeVersion":{"type":"string","minLength":1,"maxLength":64},"command":{"type":"string","minLength":1,"maxLength":1024},"wranglerConfigPath":{"type":"string","minLength":1,"maxLength":255},"builtAt":{"type":"string","minLength":1,"maxLength":64}},"required":["tool","toolVersion","wranglerVersion","nodeVersion","command","wranglerConfigPath","builtAt"],"additionalProperties":false},"trayoApi":{"default":null,"anyOf":[{"type":"object","properties":{"scopes":{"maxItems":64,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}}},"required":["scopes"],"additionalProperties":false},{"type":"null"}]}},"required":["schemaVersion","worker","assets","d1","r2","vars","secrets","build","trayoApi"],"additionalProperties":false},{"type":"object","properties":{"schemaVersion":{"type":"number","const":2},"worker":{"type":"object","properties":{"mainModule":{"type":"string","minLength":1,"maxLength":255},"compatibilityDate":{"type":"string"},"compatibilityFlags":{"default":[],"maxItems":16,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}}},"required":["mainModule","compatibilityDate","compatibilityFlags"],"additionalProperties":false},"assets":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"default":null,"anyOf":[{"type":"string"},{"type":"null"}]},"htmlHandling":{"default":"auto-trailing-slash","type":"string","enum":["auto-trailing-slash","force-trailing-slash","drop-trailing-slash","none"]},"notFoundHandling":{"default":"none","type":"string","enum":["none","404-page","single-page-application"]},"runWorkerFirst":{"default":false,"anyOf":[{"type":"boolean"},{"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":256}}]}},"required":["binding","htmlHandling","notFoundHandling","runWorkerFirst"],"additionalProperties":false},{"type":"null"}]},"d1":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"r2":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"vars":{"default":{},"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"secrets":{"default":[],"maxItems":32,"type":"array","items":{"type":"string"}},"build":{"type":"object","properties":{"tool":{"type":"string","minLength":1,"maxLength":64},"toolVersion":{"type":"string","minLength":1,"maxLength":64},"wranglerVersion":{"type":"string","minLength":1,"maxLength":64},"nodeVersion":{"type":"string","minLength":1,"maxLength":64},"command":{"type":"string","minLength":1,"maxLength":1024},"wranglerConfigPath":{"type":"string","minLength":1,"maxLength":255},"builtAt":{"type":"string","minLength":1,"maxLength":64}},"required":["tool","toolVersion","wranglerVersion","nodeVersion","command","wranglerConfigPath","builtAt"],"additionalProperties":false},"schedules":{"default":[],"maxItems":10,"type":"array","items":{"type":"object","properties":{"name":{"type":"string","maxLength":64},"cron":{"type":"string","maxLength":128},"timezone":{"type":"string","maxLength":64},"path":{"type":"string","maxLength":256},"description":{"default":"","type":"string","maxLength":200},"userEditable":{"default":false,"type":"boolean"}},"required":["name","cron","timezone","path","description","userEditable"],"additionalProperties":false}},"capabilities":{"default":[],"maxItems":16,"type":"array","items":{"type":"object","properties":{"id":{"type":"string","maxLength":64},"scopes":{"maxItems":64,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}},"constraints":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"why":{"type":"string","maxLength":200}},"required":["id"],"additionalProperties":false}},"egress":{"default":{"allowHosts":[]},"type":"object","properties":{"allowHosts":{"default":[],"maxItems":20,"type":"array","items":{"type":"string","maxLength":253}}},"required":["allowHosts"],"additionalProperties":false},"data":{"type":"object","properties":{"migrations":{"maxItems":200,"type":"array","items":{"type":"object","properties":{"version":{"type":"integer","minimum":1,"maximum":9007199254740991},"name":{"type":"string","pattern":"^[a-z0-9][a-z0-9_]{0,59}$"},"file":{"type":"string","maxLength":80},"sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"}},"required":["version","name","file","sha256"],"additionalProperties":false}}},"required":["migrations"],"additionalProperties":false},"flow":{"type":"object","properties":{"steps":{"maxItems":16,"type":"array","items":{"type":"object","properties":{"service":{"type":"string","maxLength":32},"action":{"type":"string","maxLength":64},"kind":{"type":"string","maxLength":16}},"required":["service","action","kind"],"additionalProperties":false}}},"required":["steps"],"additionalProperties":false}},"required":["schemaVersion","worker","assets","d1","r2","vars","secrets","build","schedules","capabilities","egress"],"additionalProperties":false}]},{"type":"null"}]},"resources":{"anyOf":[{"type":"object","properties":{"worker":{"type":"object","properties":{"modules":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"bytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["modules","bytes"],"additionalProperties":false},"assets":{"anyOf":[{"type":"object","properties":{"files":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"bytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["files","bytes"],"additionalProperties":false},{"type":"null"}]},"d1":{"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"r2":{"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]}},"required":["worker","assets","d1","r2"],"additionalProperties":false},{"type":"null"}]},"warnings":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false}},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"anyOf":[{"type":"object","properties":{"errors":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string","description":"Archive path, with a JSON pointer after `#` for config files."},"code":{"type":"string"},"message":{"type":"string"}},"required":["path","code","message"],"additionalProperties":false}}},"additionalProperties":{}},{"type":"null"}]}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]},"url":{"type":"string","description":"Published app URL."},"draftUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Draft URL (workspace editors only; null for others)."},"createdAt":{"type":"string","format":"date-time"},"startedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"needsMigration":{"type":"boolean","description":"This version changes stored data in a way the published version may not read (a warning: publishing is still allowed)."},"migrationFindings":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["migration","bundle"],"description":"A migration file, or SQL in the app code."},"file":{"type":"string"},"line":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"1-based line; 0 for a whole-file finding."},"kind":{"type":"string","enum":["delete_rows","drop_table","drop_view","drop_column","rename_table","rename_column","type_change","edited_migration"],"description":"What the statement does to stored data."},"statement":{"type":"string"}},"required":["source","file","line","kind","statement"],"additionalProperties":false},"description":"Why needsMigration is set (empty when it is not)."},"restorePoint":{"anyOf":[{"type":"object","properties":{"takenAt":{"type":"string","format":"date-time"},"available":{"type":"boolean","description":"False when the restore point could not be taken (the publish went ahead)."}},"required":["takenAt","available"],"additionalProperties":false},{"type":"null"}],"description":"Restore point of the app's stored data taken right before this version was last published; null if never published or the app stores no database."}},"required":["previewDataLeftBehind","id","appId","number","status","target","isDraft","isPublished","publishedAt","archive","resources","warnings","failure","url","draftUrl","createdAt","startedAt","finishedAt","needsMigration","migrationFindings","restorePoint"],"additionalProperties":false},"PublishDeploymentDto":{"type":"object","properties":{"deploymentId":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$","format":"uuid","description":"A `ready` deploy of this app, usually the current Draft. Publishing an older ready deploy rolls back."},"approveDataChanges":{"description":"Explicit confirmation of the saved-data warning in the Trayo dashboard.","type":"boolean"},"visibility":{"description":"Who can open it once published (\"only me\" = `private`, `people`, `workspace`). Owner or admin only; omit to keep the current setting (a new app stays private).","type":"string","enum":["private","people","workspace"]}},"required":["deploymentId"],"additionalProperties":false},"SourceDownloadDto":{"type":"object","properties":{"url":{"type":"string","description":"Short-lived download URL of the sealed archive (.tar.gz). Expires in 5 minutes."},"expiresAt":{"type":"string","format":"date-time"},"sha256":{"type":"string","description":"Lowercase hex SHA-256 of the archive bytes."},"sizeBytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["url","expiresAt","sha256","sizeBytes"],"additionalProperties":false},"DeploymentListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"previewDataLeftBehind":{"type":"boolean","description":"The committed first publish left some Draft data behind; the Draft copy was preserved."},"id":{"type":"string","format":"uuid"},"appId":{"type":"string","format":"uuid"},"number":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"status":{"type":"string","enum":["awaiting_upload","deploying","ready","failed"]},"target":{"type":"string","enum":["draft","published"],"description":"What the deploy was asked to install."},"isDraft":{"type":"boolean"},"isPublished":{"type":"boolean"},"publishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"archive":{"anyOf":[{"type":"object","properties":{"sha256":{"type":"string"},"sizeBytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["sha256","sizeBytes"],"additionalProperties":false},{"type":"null"}]},"manifest":{"description":"The normalized archive manifest.","anyOf":[{"oneOf":[{"type":"object","properties":{"schemaVersion":{"type":"number","const":1},"worker":{"type":"object","properties":{"mainModule":{"type":"string","minLength":1,"maxLength":255},"compatibilityDate":{"type":"string"},"compatibilityFlags":{"default":[],"maxItems":16,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}}},"required":["mainModule","compatibilityDate","compatibilityFlags"],"additionalProperties":false},"assets":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"default":null,"anyOf":[{"type":"string"},{"type":"null"}]},"htmlHandling":{"default":"auto-trailing-slash","type":"string","enum":["auto-trailing-slash","force-trailing-slash","drop-trailing-slash","none"]},"notFoundHandling":{"default":"none","type":"string","enum":["none","404-page","single-page-application"]},"runWorkerFirst":{"default":false,"anyOf":[{"type":"boolean"},{"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":256}}]}},"required":["binding","htmlHandling","notFoundHandling","runWorkerFirst"],"additionalProperties":false},{"type":"null"}]},"d1":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"r2":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"vars":{"default":{},"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"secrets":{"default":[],"maxItems":32,"type":"array","items":{"type":"string"}},"build":{"type":"object","properties":{"tool":{"type":"string","minLength":1,"maxLength":64},"toolVersion":{"type":"string","minLength":1,"maxLength":64},"wranglerVersion":{"type":"string","minLength":1,"maxLength":64},"nodeVersion":{"type":"string","minLength":1,"maxLength":64},"command":{"type":"string","minLength":1,"maxLength":1024},"wranglerConfigPath":{"type":"string","minLength":1,"maxLength":255},"builtAt":{"type":"string","minLength":1,"maxLength":64}},"required":["tool","toolVersion","wranglerVersion","nodeVersion","command","wranglerConfigPath","builtAt"],"additionalProperties":false},"trayoApi":{"default":null,"anyOf":[{"type":"object","properties":{"scopes":{"maxItems":64,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}}},"required":["scopes"],"additionalProperties":false},{"type":"null"}]}},"required":["schemaVersion","worker","assets","d1","r2","vars","secrets","build","trayoApi"],"additionalProperties":false},{"type":"object","properties":{"schemaVersion":{"type":"number","const":2},"worker":{"type":"object","properties":{"mainModule":{"type":"string","minLength":1,"maxLength":255},"compatibilityDate":{"type":"string"},"compatibilityFlags":{"default":[],"maxItems":16,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}}},"required":["mainModule","compatibilityDate","compatibilityFlags"],"additionalProperties":false},"assets":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"default":null,"anyOf":[{"type":"string"},{"type":"null"}]},"htmlHandling":{"default":"auto-trailing-slash","type":"string","enum":["auto-trailing-slash","force-trailing-slash","drop-trailing-slash","none"]},"notFoundHandling":{"default":"none","type":"string","enum":["none","404-page","single-page-application"]},"runWorkerFirst":{"default":false,"anyOf":[{"type":"boolean"},{"maxItems":20,"type":"array","items":{"type":"string","minLength":1,"maxLength":256}}]}},"required":["binding","htmlHandling","notFoundHandling","runWorkerFirst"],"additionalProperties":false},{"type":"null"}]},"d1":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"r2":{"default":null,"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"vars":{"default":{},"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"secrets":{"default":[],"maxItems":32,"type":"array","items":{"type":"string"}},"build":{"type":"object","properties":{"tool":{"type":"string","minLength":1,"maxLength":64},"toolVersion":{"type":"string","minLength":1,"maxLength":64},"wranglerVersion":{"type":"string","minLength":1,"maxLength":64},"nodeVersion":{"type":"string","minLength":1,"maxLength":64},"command":{"type":"string","minLength":1,"maxLength":1024},"wranglerConfigPath":{"type":"string","minLength":1,"maxLength":255},"builtAt":{"type":"string","minLength":1,"maxLength":64}},"required":["tool","toolVersion","wranglerVersion","nodeVersion","command","wranglerConfigPath","builtAt"],"additionalProperties":false},"schedules":{"default":[],"maxItems":10,"type":"array","items":{"type":"object","properties":{"name":{"type":"string","maxLength":64},"cron":{"type":"string","maxLength":128},"timezone":{"type":"string","maxLength":64},"path":{"type":"string","maxLength":256},"description":{"default":"","type":"string","maxLength":200},"userEditable":{"default":false,"type":"boolean"}},"required":["name","cron","timezone","path","description","userEditable"],"additionalProperties":false}},"capabilities":{"default":[],"maxItems":16,"type":"array","items":{"type":"object","properties":{"id":{"type":"string","maxLength":64},"scopes":{"maxItems":64,"type":"array","items":{"type":"string","minLength":1,"maxLength":64}},"constraints":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"why":{"type":"string","maxLength":200}},"required":["id"],"additionalProperties":false}},"egress":{"default":{"allowHosts":[]},"type":"object","properties":{"allowHosts":{"default":[],"maxItems":20,"type":"array","items":{"type":"string","maxLength":253}}},"required":["allowHosts"],"additionalProperties":false},"data":{"type":"object","properties":{"migrations":{"maxItems":200,"type":"array","items":{"type":"object","properties":{"version":{"type":"integer","minimum":1,"maximum":9007199254740991},"name":{"type":"string","pattern":"^[a-z0-9][a-z0-9_]{0,59}$"},"file":{"type":"string","maxLength":80},"sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"}},"required":["version","name","file","sha256"],"additionalProperties":false}}},"required":["migrations"],"additionalProperties":false},"flow":{"type":"object","properties":{"steps":{"maxItems":16,"type":"array","items":{"type":"object","properties":{"service":{"type":"string","maxLength":32},"action":{"type":"string","maxLength":64},"kind":{"type":"string","maxLength":16}},"required":["service","action","kind"],"additionalProperties":false}}},"required":["steps"],"additionalProperties":false}},"required":["schemaVersion","worker","assets","d1","r2","vars","secrets","build","schedules","capabilities","egress"],"additionalProperties":false}]},{"type":"null"}]},"resources":{"anyOf":[{"type":"object","properties":{"worker":{"type":"object","properties":{"modules":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"bytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["modules","bytes"],"additionalProperties":false},"assets":{"anyOf":[{"type":"object","properties":{"files":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"bytes":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["files","bytes"],"additionalProperties":false},{"type":"null"}]},"d1":{"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]},"r2":{"anyOf":[{"type":"object","properties":{"binding":{"type":"string"}},"required":["binding"],"additionalProperties":false},{"type":"null"}]}},"required":["worker","assets","d1","r2"],"additionalProperties":false},{"type":"null"}]},"warnings":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}},"required":["code","message"],"additionalProperties":false}},"failure":{"anyOf":[{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"anyOf":[{"type":"object","properties":{"errors":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string","description":"Archive path, with a JSON pointer after `#` for config files."},"code":{"type":"string"},"message":{"type":"string"}},"required":["path","code","message"],"additionalProperties":false}}},"additionalProperties":{}},{"type":"null"}]}},"required":["code","message"],"additionalProperties":false},{"type":"null"}]},"url":{"type":"string","description":"Published app URL."},"draftUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Draft URL (workspace editors only; null for others)."},"createdAt":{"type":"string","format":"date-time"},"startedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"needsMigration":{"type":"boolean","description":"This version changes stored data in a way the published version may not read (a warning: publishing is still allowed)."},"migrationFindings":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["migration","bundle"],"description":"A migration file, or SQL in the app code."},"file":{"type":"string"},"line":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"1-based line; 0 for a whole-file finding."},"kind":{"type":"string","enum":["delete_rows","drop_table","drop_view","drop_column","rename_table","rename_column","type_change","edited_migration"],"description":"What the statement does to stored data."},"statement":{"type":"string"}},"required":["source","file","line","kind","statement"],"additionalProperties":false},"description":"Why needsMigration is set (empty when it is not)."},"restorePoint":{"anyOf":[{"type":"object","properties":{"takenAt":{"type":"string","format":"date-time"},"available":{"type":"boolean","description":"False when the restore point could not be taken (the publish went ahead)."}},"required":["takenAt","available"],"additionalProperties":false},{"type":"null"}],"description":"Restore point of the app's stored data taken right before this version was last published; null if never published or the app stores no database."}},"required":["previewDataLeftBehind","id","appId","number","status","target","isDraft","isPublished","publishedAt","archive","resources","warnings","failure","url","draftUrl","createdAt","startedAt","finishedAt","needsMigration","migrationFindings","restorePoint"],"additionalProperties":false},"description":"Deployments, newest first. `manifest` is omitted; read a single deploy for it."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"EmptyBodyDto":{"type":"object","properties":{},"additionalProperties":false},"SetupLinkDto":{"type":"object","properties":{"url":{"type":"string","description":"Open in a browser signed in to Trayo with permission to manage apps."},"expiresAt":{"type":"null"}},"required":["url","expiresAt"],"additionalProperties":false},"SetSecretDto":{"type":"object","properties":{"value":{"type":"string","minLength":1,"description":"The secret value. Write-only: it is never returned by any API."}},"required":["value"],"additionalProperties":false},"SecretSetDto":{"type":"object","properties":{"name":{"type":"string"},"updatedAt":{"type":"string","format":"date-time"}},"required":["name","updatedAt"],"additionalProperties":false},"SecretDeletedDto":{"type":"object","properties":{"name":{"type":"string"},"deleted":{"type":"boolean","const":true}},"required":["name","deleted"],"additionalProperties":false},"AppScheduleListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string"},"cron":{"type":"string","description":"5-field cron, evaluated in `timezone`."},"cronText":{"description":"The cadence in words.","type":"string"},"timezone":{"type":"string"},"path":{"type":"string"},"enabled":{"type":"boolean"},"userEditable":{"type":"boolean"},"nextRunAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lastRunAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"lastStatus":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","cron","timezone","path","enabled","userEditable","nextRunAt","lastRunAt","lastStatus"],"additionalProperties":false}}},"required":["data"],"additionalProperties":false},"AppScheduleRunListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Run id."},"trigger":{"type":"string","enum":["cron","manual","api"]},"scheduledFor":{"type":"string","format":"date-time"},"startedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"finishedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"status":{"type":"string","enum":["running","ok","error","timeout","skipped_overlap","skipped_inactive","skipped_budget"],"description":"`skipped_*`: the run did not execute (previous run still going, app paused, or daily run budget used)."},"httpStatus":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Redacted, at most 2000 characters."},"deploymentId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","trigger","scheduledFor","startedAt","finishedAt","status","httpStatus","durationMs","error","deploymentId"],"additionalProperties":false},"description":"Runs of this schedule, newest first, kept for 30 days."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false},"UpdateAppScheduleDto":{"type":"object","properties":{"enabled":{"description":"Turn the schedule on or off.","type":"boolean"},"cron":{"description":"New 5-field cadence (only when the schedule is `userEditable`). At least 5 minutes between runs.","type":"string","minLength":1,"maxLength":100},"timezone":{"description":"IANA time zone (only when `userEditable`).","type":"string","minLength":1,"maxLength":64}},"additionalProperties":false},"RunScheduleResponseDto":{"type":"object","properties":{"runId":{"type":"string","description":"Id of the run; it appears in the run history."}},"required":["runId"],"additionalProperties":false},"AppErrorListDto":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"occurredAt":{"type":"string","format":"date-time"},"variant":{"type":"string","enum":["published","draft"]},"source":{"type":"string","enum":["http","schedule","tail"]},"requestId":{"anyOf":[{"type":"string"},{"type":"null"}]},"method":{"anyOf":[{"type":"string"},{"type":"null"}]},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Request path without the query string."},"status":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"errorClass":{"anyOf":[{"type":"string"},{"type":"null"}]},"message":{"type":"string","description":"Redacted."},"stack":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Redacted."},"deploymentId":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}]}},"required":["id","occurredAt","variant","source","requestId","method","path","status","errorClass","message","stack","deploymentId"],"additionalProperties":false},"description":"Runtime errors, newest first, kept for 7 days."},"hasMore":{"type":"boolean","description":"Another page exists. When `false` you have reached the end — stop requesting pages."},"nextCursor":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Pass verbatim as the next request's `cursor` to fetch the next page. Null exactly when `hasMore` is false."}},"required":["data","hasMore","nextCursor"],"additionalProperties":false}}},"security":[{"bearerAuth":[]}],"x-recipes":[{"id":"start-here","title":"First three calls with a new key","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.","steps":[{"operationId":"whoami","note":"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.","method":"GET","path":"/v1/whoami","summary":"Who this key is: permissions, workspace and limits"},{"operationId":"getWorkspace","note":"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\"`.","method":"GET","path":"/v1/workspace","summary":"Read workspace onboarding assumptions and settings"},{"operationId":"updateWorkspace","note":"Set them, in prose, BEFORE you run discovery — they steer work that starts after they are set, not retroactively.","optional":true,"method":"PATCH","path":"/v1/workspace","summary":"Correct workspace onboarding assumptions and settings"},{"operationId":"listRecipes","note":"Pick the recipe for the task at hand from this list.","optional":true,"method":"GET","path":"/v1/recipes","summary":"The call sequences that make up a task"}]},{"id":"discover-account-events","title":"Run discovery for companies and read their 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":"Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. ","steps":[{"operationId":"batchCreateAccounts","note":"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.","method":"POST","path":"/v1/accounts/batch","summary":"Batch create accounts"},{"operationId":"createSignal","note":"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.","method":"POST","path":"/v1/signals","summary":"Create a signal"},{"operationId":"startDiscovery","note":"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.","method":"POST","path":"/v1/discoveries","summary":"Start a discovery run"},{"operationId":"getDiscovery","note":"`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.","method":"GET","path":"/v1/discoveries/{runId}","summary":"Get a discovery run"},{"operationId":"listEvents","note":"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.","method":"GET","path":"/v1/events","summary":"List events"}],"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`)."},{"id":"lookalike-companies","title":"Find companies like these","when":"You have example companies and want a ranked list of similar companies.","spends":"Nothing. This search is not metered.","steps":[{"operationId":"findLookalikeCompanies","note":"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.","method":"POST","path":"/v1/accounts/lookalikes","summary":"Find companies similar to a list of seed companies"}],"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."},{"id":"competitor-roster","title":"Rank a company's competitors","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.","steps":[{"operationId":"generateCompetitorRoster","note":"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.","method":"POST","path":"/v1/companies/competitors","summary":"Rank the competitors of one company"}],"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."},{"id":"prospect-to-list","title":"Find companies or people you do not have, and keep the good ones","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`.","steps":[{"operationId":"find","note":"`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.","method":"POST","path":"/v1/find","summary":"Find companies or people not yet in your workspace"},{"operationId":"batchCreateAccounts","note":"Bring the companies in. Deduplicated on hostname, then on `linkedinHandle`; a repeat is `alreadyAdded`, not an error.","optional":true,"method":"POST","path":"/v1/accounts/batch","summary":"Batch create accounts"},{"operationId":"batchCreatePeople","note":"Bring the people in. A person needs an account to belong to, so create accounts first.","optional":true,"method":"POST","path":"/v1/people/batch","summary":"Batch create people"},{"operationId":"createList","note":"A named working set that outlives this session and shows up in the Trayo app under the user your key acts as.","method":"POST","path":"/v1/lists","summary":"Create a list"},{"operationId":"addListMembers","note":"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.","method":"POST","path":"/v1/lists/{id}/members","summary":"Add members"}],"then":"Hand the LIST to whatever acts on it — a CRM push, a campaign, a person — rather than your session's memory of it."},{"id":"stakeholders-at-a-company","title":"Find the right people at one company and get their contact details","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`.","steps":[{"operationId":"researchCompany","note":"A few seconds: what the company does, `whyNow`, and `fit` against what you sell.","optional":true,"method":"POST","path":"/v1/research/company","summary":"A quick overview of a company: what it does, why now, and fit"},{"operationId":"searchStakeholders","note":"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.","method":"POST","path":"/v1/stakeholders/search","summary":"Find the stakeholders at a company that fit a definition"},{"operationId":"batchCreatePeople","note":"Import the ones you want; a person must belong to an account, so create it first if needed.","method":"POST","path":"/v1/people/batch","summary":"Batch create people"},{"operationId":"enrichPerson","note":"Explicit, asynchronous (202), idempotent. Re-sending never double-runs or spends twice; `skipped` says why a field was not started.","method":"POST","path":"/v1/people/{id}/enrich","summary":"Look up an email and/or phone"},{"operationId":"getPersonContact","note":"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.","method":"GET","path":"/v1/people/{id}/enrich","summary":"Read the contact lookup state"}]},{"id":"contact-without-importing","title":"One person's email or phone, without adding them to the workspace","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.","steps":[{"operationId":"lookupContact","note":"`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.","method":"POST","path":"/v1/contacts/lookup","summary":"Find an email or phone for a person you describe"},{"operationId":"getContactLookup","note":"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.","optional":true,"method":"GET","path":"/v1/contacts/lookups/{lookupId}","summary":"Collect a lookup that was still running"}]},{"id":"brief-before-outreach","title":"What to know about a person before writing to them","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.","steps":[{"operationId":"researchPerson","note":"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.","optional":true,"method":"POST","path":"/v1/research/person","summary":"A quick overview of a person: career, likely challenges, and angles"},{"operationId":"askPerson","note":"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.","optional":true,"method":"POST","path":"/v1/people/{id}/ask","summary":"Ask a question about a person"}],"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."},{"id":"exact-prospecting","title":"Companies and people by exact criteria","when":"Your target is a WHERE clause — an industry, a headcount band, a country, a funding stage, 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`.","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.","steps":[{"operationId":"listFindIndustries","note":"Industry is a closed taxonomy. Pick exact values here (`?contains=software`) — an unlisted value matches nothing.","method":"GET","path":"/v1/find/industries","summary":"The industry taxonomy `filters.industries` accepts"},{"operationId":"find","note":"`target: \"companies\"` with `filters`. At least one of industries / hq.cities / hq.states / fundingStages / 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.","optional":true,"method":"POST","path":"/v1/find","summary":"Find companies or people not yet in your workspace"},{"operationId":"find","note":"`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.","method":"POST","path":"/v1/find","summary":"Find companies or people not yet in your workspace"},{"operationId":"batchCreateAccounts","note":"Track the companies: each row carries `name` and a ready `url`.","optional":true,"method":"POST","path":"/v1/accounts/batch","summary":"Batch create accounts"},{"operationId":"batchCreatePeople","note":"Import the people you want to work; `linkedinUrl` carries over.","optional":true,"method":"POST","path":"/v1/people/batch","summary":"Batch create people"}],"then":"Contact details as in `contact-without-importing`; a brief as in `brief-before-outreach`."},{"id":"recent-movers","title":"People who just changed company","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.","steps":[{"operationId":"searchJobChanges","note":"`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.","method":"POST","path":"/v1/job-changes/search","summary":"Find recent company moves"},{"operationId":"batchCreatePeople","note":"Import the movers you want to work.","optional":true,"method":"POST","path":"/v1/people/batch","summary":"Batch create people"},{"operationId":"enrichPerson","note":"Then contact details, as in `stakeholders-at-a-company`.","optional":true,"method":"POST","path":"/v1/people/{id}/enrich","summary":"Look up an email and/or phone"}]},{"id":"signal-triggered-prospecting","title":"Each run: new companies, their recent signals, a few people each, their emails","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":["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."],"steps":[{"operationId":"whoami","note":"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.","method":"GET","path":"/v1/whoami","summary":"Who this key is: permissions, workspace and limits"},{"operationId":"listSignals","note":"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.","method":"GET","path":"/v1/signals","summary":"List signals"},{"operationId":"find","note":"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`.","method":"POST","path":"/v1/find","summary":"Find companies or people not yet in your workspace"},{"operationId":"batchCreateAccounts","note":"`{\"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`.","method":"POST","path":"/v1/accounts/batch","summary":"Batch create accounts"},{"operationId":"startDiscovery","note":"`{\"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`.","method":"POST","path":"/v1/discoveries","summary":"Start a discovery run"},{"operationId":"getDiscovery","note":"`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.","method":"GET","path":"/v1/discoveries/{runId}","summary":"Get a discovery run"},{"operationId":"listEvents","note":"`?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.","method":"GET","path":"/v1/events","summary":"List events"},{"operationId":"searchStakeholders","note":"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.","method":"POST","path":"/v1/stakeholders/search","summary":"Find the stakeholders at a company that fit a definition"},{"operationId":"batchCreatePeople","note":"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.","method":"POST","path":"/v1/people/batch","summary":"Batch create people"},{"operationId":"batchEnrichPeople","note":"`{\"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.","method":"POST","path":"/v1/people/enrich/batch","summary":"Look up emails and phones for many people"},{"operationId":"getPersonContact","note":"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.","method":"GET","path":"/v1/people/{id}/enrich","summary":"Read the contact lookup state"}],"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."},{"id":"buying-committee-for-a-company","title":"Map the buying committee at one 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":["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."],"steps":[{"operationId":"whoami","note":"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.","method":"GET","path":"/v1/whoami","summary":"Who this key is: permissions, workspace and limits"},{"operationId":"getWorkspace","note":"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.","method":"GET","path":"/v1/workspace","summary":"Read workspace onboarding assumptions and settings"},{"operationId":"researchCompany","note":"`{\"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.","optional":true,"method":"POST","path":"/v1/research/company","summary":"A quick overview of a company: what it does, why now, and fit"},{"operationId":"searchStakeholders","note":"`{\"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.","method":"POST","path":"/v1/stakeholders/search","summary":"Find the stakeholders at a company that fit a definition"},{"operationId":"batchCreateAccounts","note":"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.","optional":true,"method":"POST","path":"/v1/accounts/batch","summary":"Batch create accounts"},{"operationId":"batchCreatePeople","note":"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.","optional":true,"method":"POST","path":"/v1/people/batch","summary":"Batch create people"},{"operationId":"batchEnrichPeople","note":"Emails, as in `signal-triggered-prospecting`: `{\"personIds\":[...],\"fields\":[\"email\"]}`.","optional":true,"method":"POST","path":"/v1/people/enrich/batch","summary":"Look up emails and phones for many people"},{"operationId":"getPersonContact","note":"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.","optional":true,"method":"GET","path":"/v1/people/{id}/enrich","summary":"Read the contact lookup state"}],"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."}]}