---
title: "Get a discovery run"
method: GET
path: "/v1/discoveries/{runId}"
tags: ["Discoveries"]
---

# Get a discovery run

`GET /v1/discoveries/{runId}`

## Request

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

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

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

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

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

## Path parameters

- `runId` string, uuid, required

## Response `200`

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.

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

## Other responses

- `400`: `runId` is not a UUID. `validation_failed` (`retry: fix_input`) — The request did not pass input validation: a missing or malformed body field, a bad `limit`, a path parameter that is not a UUID, a `signalKey` that already exists or is reserved. `message` lists every failed constraint; `param` names the first offending field when one can be identified.
- `401`: 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`: `discovery_run_not_found` (`retry: do_not_retry`) — No such run in your workspace.
- `429`: Every route is rate limited per workspace across REST, MCP, all keys and OAuth users, `GET /v1/whoami` included, so a balance poll competes with the work it is watching. For `rate_limited`, wait `Retry-After` seconds (also `details.retryAfterSeconds`), then send the same request once; do not retry sooner. `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` describe the workspace's bucket, and they come back on successful responses too: read them and slow down BEFORE you are refused. A burst from one network can also be refused before the key is checked; that 429 carries `Retry-After` and no `RateLimit-*` headers. `rate_limited` (`retry: retry_later`) — The workspace or source network exhausted its request budget for one bucket. `details.retryAfterSeconds` (and `Retry-After` on REST) is the wait: wait that long, then retry the same request once. Do not poll or retry sooner; an early retry is refused again. If you cannot wait, stop and tell the user when to retry; do not fill the wait with other calls. `details.limit` and `details.bucket` name the exhausted budget. When present, RateLimit-* headers describe the active budget and reset.
- `500`: Something failed on our side. Nothing about the request needs changing. `internal_error` (`retry: retry_later`) — Something failed on our side. `message` never describes it — quote `requestId` when reporting. Retry with exponential backoff and GIVE UP after a few attempts: `retry_later` here does not mean the failure is known to be transient, only that nothing about your request needs changing. A request that fails this way repeatedly will not start working.

---

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