POST /v1/jobs accepts exactly the same
request body as POST /v1/query, answers 202 Accepted with a job id in
milliseconds, and produces the answer in the background. You poll
GET /v1/jobs/{id} until the job is finished and read a result that is
byte-for-byte the /v1/query 200 body you would have received
synchronously. Same validation, same errors, same answer shape.
When to use it (and when not to)
Use an async job when your caller cannot wait on the socket. If you can wait, usePOST /v1/query — it is the same work, and the sync call is always
at least as fast (a job never runs faster than a sync call; it just doesn’t
make you hold the connection). A job is also a bad fit if you need the answer
in under ~10 seconds; nothing about the background path makes generation
faster.
The loop: enqueue, poll with backoff, read
1
Enqueue
POST /v1/jobs with your long-lived key and a /v1/query body. You get
202 and a receipt:poll_url is a path relative to the base URL you called. Keep
request_id — support can find both the submission and its background
run with it.2
Poll with backoff, branching on the HTTP status
Wait about a second,
GET the poll URL, and keep going until status is
succeeded, failed, or expired. Back off between polls (start at 1 s,
multiply by 1.5, cap at 10 s). Handle each poll by its HTTP status:2xx— readstatusfrom the body and continue or stop accordingly.429or503— sleepmax(Retry-After, 1)seconds and poll again. Neither says anything about the job: a429is your rate budget, and a503withRetry-Afterwhile polling is transient — the job is unaffected and keeps running in the background.- Anything else (
401,403,404,500, …) — stop and inspect; polling again will not change the answer. A404afterexpires_atmeans the job is no longer retained — submit a new one.
X-RateLimit-Remaining is on every response — every poll is a metered
request against the same per-key budget as /v1/query. If it’s getting
low, slow down; a tight polling loop is the easiest way to 429 yourself
out of your own answers.A grounded answer typically lands in 10–40 seconds, so a sane loop makes
5–10 polls. Concurrency budget: at the default 60 requests/minute,
more than ~8 jobs in flight at once will 429 your polls — start each loop
from the X-RateLimit-Remaining value on the 202 and size your
concurrency from it (or ask for a higher per-key budget).3
Read the result
On
succeeded, result is the /v1/query response: answer,
citations, optional deferral / crisis / error, and
structured_answer if you asked for response_format: "structured".
Handle it exactly as you handle a sync response — including the deferral
case, where answer is empty and the corpus lacked strong evidence. On
failed or expired, branch on error_code (below) to decide whether
to resubmit.Statuses
Terminal states never change again. A job can go straight from
queued to
expired if it was never started in time.
error_code is one of exactly six values — generation_failed,
corpus_unavailable, empty_query, invalid_payload, internal,
expired — and is null while the job is queued, running, or
succeeded.
Expiry and retention
Every job has anexpires_at, one hour after submission. Three rules:
- Still
queuedorrunningatexpires_at: the next read marks itexpiredand returns it once. You will never be shown a stale in-flight status past the deadline. - Seen
runningfor more than 2 minutes: treated as expired on the next read, regardless ofexpires_at. The background run cannot outlive that window, so a job stillrunningafter it has died (deploy, crash, timeout) has no answer coming — you learn within minutes, not an hour. - Already finished (
succeeded/failed/expired): readable untilexpires_at, and not after. The first read past the deadline answers404 not_found, and at that moment the stored request (payload) and answer (result) are cleared. Rows are then deleted outright within about 24 hours ofexpires_at, whether or not anyone read them. Fetch your result inside the hour.
Maximum retention horizon: your question text and the generated answer
exist on our side for at most
expires_at + ~24 hours (≈ 25 hours after
submission), and are unreadable through the API after expires_at.Validation — the same rules as /v1/query, applied at submission
Everything /v1/query rejects with 400, POST /v1/jobs rejects with the
same 400 before creating a job: unknown fields (including
retrieval-tuning knobs), both or neither of question/messages, more than
one message, a non-user role, a message with a file part, an empty or
whitespace-only question (zero-width and other invisible characters count as
blank). You will never wait on a job only to learn the request was
malformed. One rule exists only here: because the body is stored and echoed
back on every poll, it is capped at 32 KiB of JSON.
Keys and ownership
Server-to-server only, with your long-lived partner key — both operations refuse session tokens with403 forbidden. A job belongs to your
partner, not to the individual key that created it: any of your
long-lived keys can poll any of your jobs. A job id that doesn’t exist and a
job id that belongs to another partner both return the identical
404 not_found.
Metering
Submitting a job is one metered request; each poll is one more. The background execution is never metered again. Budget accordingly: a job that takes 30 seconds and is polled sensibly costs roughly 6–8 requests of your per-minute budget, versus 1 for a sync call. See Errors and rate limits for the shared budget. Full request and response schemas (JobResponse, error_code values, and
every status code) are in the API Reference tab under Async jobs.