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.
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.
# 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.
{
"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.
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.
| Key | How it's set | Dedupe window |
|---|---|---|
| Client key | You send Idempotency-Key: <=200 chars> | 24 hours |
| Derived key | Auto: hash of API key id + request body | 90 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.
# 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:
| Situation | Response | Charged? |
|---|---|---|
| Original submit already completed | Original job handle, with header Idempotent-Replay: true | No |
| A duplicate is still in flight | 409 with Retry-After: 2 | No |
| Claim store unreachable (fail closed) | 503 with Retry-After: 2 | No |
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.