Docs
Sign inStart free
Documentation/Async research: submit, poll & idempotency

Async research: submit, poll & idempotency

Research is the only async service: POST to submit (charged $0.08 once), then poll GET /v1/research/run/{id} for free until completed — polling is the source of truth. Send an Idempotency-Key and a retry never double-charges; even without one, naive retries inside 90s auto-dedupe. Research-only, on purpose.

5 min readHumans & agentsopen as text

01Submit then poll

Research is the only async service. Search, Content, and Answers are synchronous — one request, one response, no job handle and nothing to poll. Everything on this page is Research-only, on purpose.

A research job has two calls. You submit with POST /v1/research/run and get a job handle back instantly; then you poll GET /v1/research/run/{id} until the job reaches a terminal state. Most jobs finish in 5–15 minutes and read up to ~40 sources, so the work happens off the request path — submit returns in moments, the report arrives on a later poll. Design for the job handle rather than an expected duration: a busy or source-heavy job can take longer.

Submit is the only billable event: $0.08 is charged once on a successful submit (statusCode < 400), regardless of depth. Polling is free — a poll writes no usage row — but it still requires the same bearer auth as every other call.

Submit, then poll the handle
# 1) Submit the job — charged $0.08 once, returns a job handle.
curl -X POST https://api.stelq.com/v1/research/run \
  -H "Authorization: Bearer $STELQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"Map AI-native search startups and their moats","depth":"deep","citations":true}'
# -> {"id":"rsch_8K2mQ9vX3pLw","status":"queued"}

# 2) Poll (free, auth still required) every 2–5s until status is terminal.
curl https://api.stelq.com/v1/research/run/rsch_8K2mQ9vX3pLw \
  -H "Authorization: Bearer $STELQ_KEY"

The documented lifecycle is queued → in_progress → completed, with terminal states completed, failed, or cancelled. On completed you read the result fields: result.outputText (render-ready prose), result.reportMarkdown (long-form), result.citations (source URLs), and result.dossier (the full structured retrieval packet). error is populated only on failed/cancelled.

Completed poll body
{
  "id": "rsch_8K2mQ9vX3pLw",
  "status": "completed",
  "result": {
    "outputText": "AI-native search startups cluster around three moats…",
    "reportMarkdown": "# AI-native search startups\n\n## Moats\n…",
    "citations": [
      "https://eur-lex.europa.eu/eli/reg/2024/1689",
      "https://example.com/ai-search-landscape"
    ],
    "dossier": { "…": "full structured retrieval packet" }
  }
}

The lifecycle and result field names are a documentation contract with the engine. The gateway passes the engine's response body through untouched — it does not synthesize { id, status } and does not enforce these field names. Treat the schema as the contract; read defensively.

Two different ids. The X-Request-Id header on the submit response is the gateway's billing id for that submit. The id you poll on is the engine job id inside the body (the rsch_… handle). They are distinct — poll the body's id, not the X-Request-Id header.

Polling is always the source of truth. You can optionally pass a webhook on submit to be notified on completion and skip polling, but a webhook is an optimization on top of polling, never a replacement. See Webhooks.

02Idempotency on submit

The Idempotency-Key header is honored on the Research submit call and nowhere else. It is read in the submit handler only — not on the poll, not on Search/Content/Answers (which are synchronous), and not via MCP. Sending it elsewhere has no effect.

Submit is the one billed call that creates a side effect (an upstream job) and charges on success, so a naive client or agent retry could otherwise create two jobs and two $0.08 charges. To prevent that, the gateway dedupes at the submit boundary on an idempotency key, claimed before the upstream job is created — so a duplicate never makes a second job or a second charge.

There are two windows. Send a client Idempotency-Key (trimmed, max 200 chars) and that key dedupes for 24 hours. Send no header and the gateway derives a content-hash key from your API key id plus the request body and dedupes for exactly 90 seconds — enough to absorb naive submit retries without swallowing a deliberate re-run.

KeyHow it's setDedupe window
Client keyYou send Idempotency-Key: <=200 chars>24 hours
Derived keyAuto: hash of API key id + request body90 seconds

A deliberate re-run after the window expires charges again. With no Idempotency-Key, re-submitting the same body more than 90 seconds later is a new, billable job ($0.08). Send a client key if you need a longer guarantee.

Retry-safe submit with a client key
# Same key on every retry of THIS submit -> at most one job, one charge for 24h.
curl -X POST https://api.stelq.com/v1/research/run \
  -H "Authorization: Bearer $STELQ_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: job-2026-06-16-moats-001" \
  -d '{"query":"Map AI-native search startups and their moats","depth":"deep"}'

The claim is atomic — a UNIQUE (api_key_id, idem_key) constraint means exactly one caller wins the claim and everyone else inspects the existing row. That yields three replay outcomes for a duplicate submit:

SituationResponseCharged?
Original submit already completedOriginal job handle, with header Idempotent-Replay: trueNo
A duplicate is still in flight409 with Retry-After: 2No
Claim store unreachable (fail closed)503 with Retry-After: 2No

On a completed replay the gateway returns the original stored job handle and the Idempotent-Replay: true header, with no upstream call and no charge. A genuine in-flight duplicate gets 409 Retry-After: 2 — retry shortly. If the claim store can't be reached, submit fails closed with 503 Retry-After: 2 rather than risk charging a paid, side-effecting call twice.

Guarantee: at-most-once-charge / effectively-once-submit. The charge is committed best-effort after the upstream job is created; a missed commit under-charges (safer than over-charging) and self-heals when the claim expires. usage_events.request_id is a second dedupe layer behind the claim. For the 409/503 you may see here, see Errors & status codes.

03Related