Skip to main content
GET
Poll an async job's status

Authorizations

Authorization
string
header
required

Bearer token used by every /v1/* endpoint. Two kinds of key work here:

  • Long-lived partner key (cxh_pk_live_*) — issued by CollectiveX Health (contact partners@collectivex.health). Keep it server-side only — never expose it in client bundles or mobile app code. This rule is absolute and does not change for any endpoint.
  • Short-lived session key — minted server-side via POST /v1/session-tokens from a long-lived key. Safe to hand to a native mobile client only — not a browser or other JavaScript context, see "API keys" in the description above. It's scoped to /v1/query and /v1/chat only, expires in at most 60 minutes, and can't mint further session keys. POST /v1/session-tokens itself only ever accepts the long-lived key.

Path Parameters

id
string<uuid>
required

The job's id, as returned when it was created.

Response

OK. The job's current state.

Current state of one async job. payload is the request the job was created with and result is its output; both are typed per job_type. The only job type today is query, whose payload is a QueryRequest and whose result is a QueryResponse.

request_id
string<uuid>
required

UUID minted per request. Use for support correlation across PostHog / Langfuse / logs.

id
string<uuid>
required

The job's own id (distinct from request_id, which identifies this poll request).

job_type
string
required

Identifies what kind of work this job represents and therefore the shape of payload/result. Today always query (an asynchronous /v1/query). Not an enum in this spec — a future job type would be an additive minor bump, with its own payload/result shape documented alongside.

Example:

"query"

status
enum<string>
required

Current lifecycle state. queued → running → one of succeeded / failed / expired (terminal); queued can also go directly to expired if never started in time. A read never shows a stale in-flight state: a job past expires_at, or one seen running for more than 2 minutes (the background run cannot live that long — it died), is moved to expired by the read itself. expired carries error_code: "expired".

Available options:
queued,
running,
succeeded,
failed,
expired
payload
object
required

The job's input, exactly as it was created with (after validation — unknown fields were rejected at submission). For job_type: "query" this is the QueryRequest you sent.

result
null | object
required

The job's output. null until status is succeeded. For job_type: "query" this is byte-for-byte the 200 body POST /v1/query would have returned for the same request — including structured_answer when response_format: "structured" was requested, and the optional deferral / crisis / error fields with the same meaning. A request /v1/query would have answered 502 becomes a failed job instead (see error).

error
null | string
required

Generic human-readable failure message, present when status is failed. Safe to display; do not parse — branch on error_code. The messages are the same texts /v1/query uses on its own error paths ("Generation failed. Please retry.", "Research corpus temporarily unavailable. Please retry.", …) — never an internal error, never your input echoed back. This is enforced at the write boundary (lib/jobs/store.ts's failJob), not merely promised: a message that is empty, over 500 characters, or looks like it could carry an internal detail is replaced with a generic one before it is stored.

Maximum string length: 500
expires_at
null | string<date-time>
required

When this job and its result stop being readable. Set on every job created through POST /v1/jobs (one hour after submission). A job still queued/running at this instant is expired on the next read. A job that already finished is readable until this instant and not after: the first read past expires_at returns 404 not_found and clears the stored payload and result; the row is deleted within about 24 hours. Fetch your result before then. null only for jobs created without a deadline (none today) — such a job is never expired or cleared by the read path.

created_at
string<date-time>
required

When the job was created.

updated_at
string<date-time>
required

When the job's status/result last changed.

error_code
null | enum<string>

Machine-readable failure class — the field to branch on. Set whenever status is failed or expired; null otherwise. Bounded enum (also enforced by a database CHECK):

  • corpus_unavailable — the research corpus was unreachable during execution. Transient: submit a new job with backoff. (At submission time the same outage never occurs — the corpus is not touched until the job runs.)
  • internal — an unexpected server-side error. Transient: submit a new job with backoff; if it repeats, contact support with the job id.
  • generation_failed — the pipeline ran but produced nothing usable (the sync route's 502). Not transient in the retry sense: retrying the identical question usually yields the same outcome; rephrase or treat as unanswerable.
  • empty_query — the question was blank once normalised. Fix the request.
  • invalid_payload — the stored request no longer passes validation. Should never occur; contact support.
  • expired — the job hit its deadline or was seen running for more than 2 minutes without finishing. Submit a new job.
Available options:
generation_failed,
corpus_unavailable,
empty_query,
invalid_payload,
internal,
expired
attempts
integer

How many times execution of this job was started. 0 while queued; 1 once it is running or terminal. The current runner never retries, so a value above 1 is not expected — the field exists so that a future retrying runner needs no contract change.

Required range: x >= 0
started_at
null | string<date-time>

When execution last started. null while queued.