---
title: "Find recent company moves"
method: POST
path: "/v1/job-changes/search"
tags: ["People"]
---

# Find recent company moves

`POST /v1/job-changes/search`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/job-changes/search`
- Auth: HTTP bearer

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

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

## Request body

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

## Response `200`

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

## Other responses

- `400`: None of source, destination or title was sent, `title` or `destinationFilters` was combined with a company, title had no any or all terms, `startedSince` or `startedBefore` was not a valid calendar month/date in YYYY-MM or YYYY-MM-DD form, either company is malformed, both resolve to the same company, `detectedSince` did not parse, `seniority` was not one of cxo, vp, director, head, `cursor` was sent to a sampled source-only backend, the start-month range was empty or reversed, or `cursor` did not decode. `validation_failed` (`retry: fix_input`) — The request did not pass input validation: a missing or malformed body field, a bad `limit`, a path parameter that is not a UUID, a `signalKey` that already exists or is reserved. `message` lists every failed constraint; `param` names the first offending field when one can be identified. `invalid_date` (`retry: fix_input`) — A date filter (`updatedSince`, `since`, `detectedSince`) did not parse. Send a plain date (`2026-06-07`, read as midnight UTC) or a full ISO-8601 timestamp (`2026-06-07T14:30:00Z`). Relative words such as `yesterday` or `last-quarter` are not accepted. `param` names the field. `malformed_cursor` (`retry: fix_input`) — `cursor` was not a value this API issued. Send a previous page's `nextCursor` verbatim, or omit it to start over.
- `401`: Missing, malformed, or incorrect bearer token. Every way authentication can fail returns the identical body, so do not try to infer which one it was, and do not repeat the call with the same key. `unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.
- `403`: The key grant and its owner's current workspace role must both include every scope required by the operation. `insufficient_scope` (`retry: do_not_retry`) — Scope denial details distinguish missing_key_scope (use a key granting every missing scope), owner_permission_limit (the owner needs the missing workspace permissions), and invalid_key_scope (replace the key with supported scopes). Membership and undeclared-route denials remain generic.
- `404`: `account_not_found` (`retry: do_not_retry`) — No such account in your workspace. Another workspace's account is indistinguishable from a non-existent one. `company_not_found` (`retry: do_not_retry`) — We could not identify this company — not from the account's own record, nor from a LinkedIn page, website (including its redirect), or name — so there are no people to search. Retrying will not help. Send the company's own website if you did not; for any company, `POST /v1/find` on the contacts lane searches the web instead. From `POST /v1/find` with `filters.companyWebsites`, this means NO website resolved and nothing was searched; the message names every website. For one that is ambiguous (several possible companies on the domain) it also names the candidates and their ids: send the right id as `filters.companyIds`. When only some websites fail, the search runs over the rest and lists the others in `unresolvedWebsites`.
- `429`: Every route is rate limited per workspace across REST, MCP, all keys and OAuth users, `GET /v1/whoami` included, so a balance poll competes with the work it is watching. For `rate_limited`, wait `Retry-After` seconds (also `details.retryAfterSeconds`), then send the same request once; do not retry sooner. `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` describe the workspace's bucket, and they come back on successful responses too: read them and slow down BEFORE you are refused. A burst from one network can also be refused before the key is checked; that 429 carries `Retry-After` and no `RateLimit-*` headers. `rate_limited` (`retry: retry_later`) — The workspace or source network exhausted its request budget for one bucket. `details.retryAfterSeconds` (and `Retry-After` on REST) is the wait: wait that long, then retry the same request once. Do not poll or retry sooner; an early retry is refused again. If you cannot wait, stop and tell the user when to retry; do not fill the wait with other calls. `details.limit` and `details.bucket` name the exhausted budget. When present, RateLimit-* headers describe the active budget and reset.
- `500`: Something failed on our side. Nothing about the request needs changing. `internal_error` (`retry: retry_later`) — Something failed on our side. `message` never describes it — quote `requestId` when reporting. Retry with exponential backoff and GIVE UP after a few attempts: `retry_later` here does not mean the failure is known to be transient, only that nothing about your request needs changing. A request that fails this way repeatedly will not start working.
- `503`: Our people data is unavailable, the bounded query failed or ran past the request budget, the search read ran out of time (`data_search_timeout`: narrow what `message` names; a timeout is not an empty result), or the workspace already has as many job-change searches running as it may (`data_search_busy`; wait `details.retryAfterSeconds`). `job_change_search_not_configured` (`retry: do_not_retry`) — Job-change search is unavailable. Retrying will not help. Contact support. `job_change_search_failed` (`retry: retry_later`) — The bounded lookup failed or ran past its time budget. Nothing was written. Send the same job-change request again shortly. `data_search_busy` (`retry: retry_later`) — Hosted search capacity (direct Find, post keyword search, job-change search, job posting search) is in use: your workspace already has as many of these searches running as it may, or the service is at capacity. Nothing ran. Wait for details.retryAfterSeconds, then retry, and send fewer at once. `data_search_timeout` (`retry: fix_input`) — A hosted search (Find by filters, with or without `mode: direct`; post keyword search; job-change search; job posting search; post search over dates older than 90 days) ran out of time. Change what `message` names and retry; resending the same search runs out of time again, and a timeout is not an empty result.

---

[API](https://skmtc.dev/trayo/apis/trayo-api.md) · [All operations](https://skmtc.dev/trayo/apis/trayo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/trayo/apis/trayo-api/revisions/0af452dce6de?raw)
