Errors
The typed error envelope and the closed code registry — what to retry, what not to
Every error response, on every /v1 endpoint:
json
{"error": {"type": "billing_error", "code": "insufficient_credits",
"message": "Not enough credits for a standard run ($10).",
"param": null, "retriable": false,
"doc_url": "/developers/errors#insufficient_credits",
"request_id": "req_a1b2c3…"}}retriable answers the only question that matters mid-loop: can an identical
retry succeed? request_id is on every response (header X-Request-Id too) —
include it in support requests.
Code registry (closed)
| Code | HTTP | Retriable | Meaning / correct response |
|---|---|---|---|
invalid_request | 400 | no | malformed input — fix the request |
parameter_renamed | 400 | no | you sent a parameter by an old name after its sunset (gh #1282: workspace_id is now blueprint_id). error.param names the old one and the message the new one: resend with the new name. Ask your human before changing saved skills or instructions that use the old name. Before the sunset the old name still works, and the response carries Deprecation/Sunset headers and a warnings entry instead |
validation_failed | 422 | no | body failed validation; error.errors lists fields |
authentication_required / invalid_api_key | 401 | no | missing/bad key |
key_revoked / key_expired | 401 | no | a key we recognise that no longer works — mint or rotate a new one at /settings/api-keys |
key_suspended / account_suspended | 403 | no | the abuse guard paused this key / account (a runaway loop, off-purpose prompts). Stop and alert a human; do not mint a new key — a fresh key of a suspended user is refused too. Re-enable from /settings/api-keys or your support contact |
insufficient_scope | 403 | no | key lacks the scope — mint one that has it |
cross_origin_refused | 403 | no | a write signed in with the browser session cookie came from a page outside this app. Never seen by a request that carries no session cookie: authenticate programmatic calls with Authorization: Bearer alone (a request that also sends the cookie is treated as the browser session) |
insufficient_role | 403 | no | the caller's current role is below what this route (or scope) needs, whatever the key carries — e.g. data:write needs member or above. A new key won't help: ask an organization admin for the role |
blueprint_forbidden | 403 | no | you may read this organization but not the Blueprint you named: your membership (or your key's creator's) is limited to specific Blueprints and this is not one of them. Ask an organization admin for access, or name a Blueprint GET /v1/blueprints lists. (Renamed from workspace_forbidden, gh #1282) |
no_blueprint_access | 403 | no | your membership is limited to specific Blueprints and none of those granted is available (none is granted, they were archived, or the organization has no Blueprint yet), so there is nothing to act on. Stop and ask a human: an organization admin must grant you a Blueprint. GET /v1/me (blueprint: null), GET /v1/blueprints (an empty list) and GET /v1/members (who your admins are) still answer. Different from no_active_blueprint: designing a Blueprint will not fix this. If we cannot READ your grants at all, you get a retriable service_unavailable instead, never this code |
not_found | 404 | no | unknown resource/entity |
conflict | 409 | no | state conflict (e.g. rotating a revoked key) |
no_subscription | 409 | no | a subscription cancel or resume for an organization with no plan |
subscription_ended | 409 | no | resuming a subscription that has already ended. Hand your human {app_url}/pricing: subscribing again is a checkout, which takes a card |
billing_update_in_progress | 409 | yes | another billing update holds the organization's billing key. Nothing was changed; retry after Retry-After (30 seconds) |
ties_unresolved | 409 | no | a merge (POST /v1/merges) would settle a tie (two rows with equally good evidence disagree on a cell) that nobody decided. The message names each open choice_key; POST /v1/merges/preview lists them as unresolved_ties. Send choices for each (survivor or loser), or accept_survivor_on_ties: true to keep the survivor's value. Ask your human when you act for one: it is their data |
merge_across_parents | 409 | no | a merge (POST /v1/merges, or its preview) names rows that sit under different parents of an entity whose names are unique per parent. Such a merge could not stick: a merge keeps names together under one parent only. The message names the parents; param is loser_ids. Merge only rows under the same parent |
merge_records_changed | 409 | yes | a merge (POST /v1/merges) found links to a merged row being written while it took its locks, retried once, and found it again. Nothing was written. Send the same call again |
merge_changed | 409 | no | a merge (POST /v1/merges) is not the merge its preview_digest confirmed: since the preview, a conflict was decided differently, a tie's values changed, a cell is newly filled, or a link a person removed since would come off the kept row. Nothing was written. (A merged row deleted since is the 404 naming it, not this.) error.details.preview is the merge as it stands now, with its own preview_digest: check what moved (ask your human when you act for one), then send that digest |
preview_required | 422 | no | a merge (POST /v1/merges) sent neither the preview_digest of the preview it confirms nor apply_without_preview: true. A merge discards data, so a blind merge must be stated. Preview first and send its preview_digest |
lock_confirmation_required | 409 | no | turning a report's monitoring back on (PATCH /v1/research/runs/{run_id}/monitoring) after its free 30 days commits 30 UTC days, and the call did not send commit_lock: true. Nothing changed. details.locked_until is when the 30 days would end (the exclusive 00:00Z end, so the last billed day is the day before it); param is commit_lock. Ask your human, then resend with commit_lock: true and expected_locked_until set to that date |
lock_quote_changed | 409 | no | the same monitoring call, sent with commit_lock: true and an expected_locked_until that is no longer the end: a UTC midnight passed after your human was quoted the date. Nothing changed. details.locked_until is the new end; param is expected_locked_until. Ask your human again with the new date, then resend with it |
retry_confirmation_required | 409 | no | a retry (POST /v1/research/runs/{run_id}/retry) would charge for a new run and was sent without expected_total_cents matching its quote. Nothing was created or charged. details is the quote: total_price_cents (the number to send back), interactions, unit_price_cents, shortfall_cents, the priced entities, the failed run's refund, max_depth, reuse_predicted, skipped_already_held, and funding (covered_by is free_run, plan_credits or balance: the total is the list price even when the free run or plan credits pay); param is expected_total_cents. Ask your human, then resend with the total, and with funding.covered_by as expected_funding so what pays is bound too |
retry_price_changed | 409 | no | the same retry, sent with an expected_total_cents that is no longer the price, or an expected_funding that is no longer what would pay (for example, plan credits spent by another run meanwhile). Nothing was charged. details is the new quote; param is expected_funding whenever the rail moved (even if the total moved too), else expected_total_cents. If the rail moved at the debit itself, the refused attempt is left as a failed run, which the next retry replaces. Ask your human again with the new quote |
rerun_price_changed | 409 | no | approving a report's waiting re-research when what is waiting changed since it was shown: the total moved, or different rows wait at the same total (the quote_id no longer matches). Nothing was charged. details carries the new total_cents and count (0 when nothing is waiting any more); param is expected_total_cents. Read GET …/reruns again, show your human the new list and total, then approve with its quote_id |
run_funding_changed | 409 | yes | POST /v1/research/runs when how the run would be paid changed before it started: it was handled as your org's free run, and it would have been paid from plan credits or the balance. Nothing was charged; the run is recorded as failed, not charged (it is listed by GET /v1/research/runs). Estimate again, confirm the price it states with your human, then create again |
run_price_changed | 409 | no | POST /v1/research/runs sent with an expected_total_cents the run now exceeds: a query read into more subjects, other seeds or depth, or a price change since the estimate. Nothing was charged or created. details is {expected_total_cents, total_price_cents, interactions, unit_price_cents}; param is expected_total_cents. Ask your human with the new total_price_cents, then resend with it. The same or a lower price is never refused |
link_not_present | 409 | no | removing a many-to-many link the record does not have: it was removed already, or never linked. Nothing changed. Read the record again before acting |
knowledge_conflict | 409 | no | a knowledge entry contradicts or repeats an active one (one host as both reliable and misleading, two ranges that cannot both hold, the same statement twice). Nothing was saved; error.details.conflicts lists each as {id, reason, why, statement}, reason one of authority_and_caution, disjoint_ranges, duplicate. Ask your human, then resend with on_conflict: "supersede" or "keep_both" |
knowledge_revision_conflict | 409 | no | a knowledge edit named the revision it edited and the entry has moved on. Nothing was saved; re-read it (error.details.current_revision) and edit again |
run_recovering | 409 | yes | the run is already retrying itself after an interruption, so a manual retry is refused for now |
run_stopping | 409 | yes | the run is still stopping; retry once it is cancelled |
cell_not_held | 409 | no | apply, keep or release on a cell nobody holds (#2196). Re-read GET /v1/holds: it may have been released already |
nothing_to_apply | 409 | no | apply on a held cell with no pending finding: research has found nothing newer, or it was applied already |
nothing_to_keep | 409 | no | keep on a held cell with no pending finding: research has found nothing newer, or it was dismissed already (#2294) |
finding_changed | 409 | no | apply or keep named a finding, and research found something newer since. Nothing was written or dismissed; the message names the newer finding. Show it to your human and decide on that one |
choice_changed | 409 | no | choose named the value row you showed your human (expected_value_evidence_id), and the cell changed since. Nothing was written; the message names the value it shows now. Read it again and choose |
not_a_candidate | 409 | no | choose named an evidence_id that is not one of the cell's values to choose from (its beside entries with choosable: true) |
nothing_to_choose | 409 | no | choose on a cell one run stated only one way: it has no other value to choose |
cell_held | 409 | no | choose on a cell holding a value someone set on purpose (chat, a merge, an applied finding). Release it first (POST /v1/cells/{cell_id}/release), then choose |
finding_unusable | 409 | no | apply named a finding research cannot hand over whole: it was too long to store in full, is not valid JSON for a structured column, or the column no longer accepts that value. Nothing was written. Edit the cell to the value your human wants |
no_active_blueprint | 409 | no | the Blueprint this call acts on (the one you named with blueprint_id, or your active one) has not been designed yet, so there is no schema to read or research into — design one at /onboarding or with POST /v1/blueprints/drafts. Approving a draft also answers this when your organization has no workspace at all; then there is nothing to approve into. Start a draft with POST /v1/blueprints/drafts instead: in an organization with no workspace, the draft gets one of its own and approving it makes that the organization's workspace |
idempotency_key_in_flight | 409 | yes | original still running — wait Retry-After |
session_busy | 409 | yes | a change to the draft is still running, or another first-run draft in the workspace is still being built — wait Retry-After, re-read the draft, then approve; if that other draft has gone live meanwhile, approving this one is human-only (403): start a new draft |
idempotency_key_reused | 422 | no | same key, different payload — new key or same body |
rate_limited | 429 | yes | back off per Retry-After |
model_allowance_exhausted | 429 | no | the ORGANIZATION's model-token allowance for this billing period is used (report chat, onboarding, the parse surfaces; a research run never counts). Retry-After is the seconds to the reset, and GET /v1/me model_allowance shows usage. A free organization's lifetime allowance is plan_upgrade_required with param: "model_allowance" instead, since only subscribing lifts it |
llm_daily_limit_exceeded | 429 | yes | the per-day cap on the free model surfaces (parsers, onboarding, chat) — a rate limit, not a billing wall: topping up does not lift it; retry after the window or ask support to raise it |
run_concurrency_exceeded | 429 | yes | your org's own run caps (set by your admin) — queue and retry |
insufficient_credits | 402 | no | balance too low — a human must top up |
plan_upgrade_required | 402 | no | the plan doesn't include this capability — topping up won't help; a human must upgrade |
budget_exceeded | 402 | no | this key's budget ceiling — a human must raise it |
payment_required | 402 | no | subscription required for this surface |
authorization_pending | 400 | yes | CLI sign-in not approved yet — keep polling at interval |
slow_down | 400 | yes | polling faster than interval — increase it, then continue |
expired_token | 400 | no | the device code expired or was already used — run deepsieve login again |
access_denied | 403 | no | the human refused the CLI sign-in; or this API key or browser-login session resolves to no organization you are an active member of (for example, you were removed from the team). GET /v1/me shows org_id: null |
internal_error | 500 | yes | our fault; retry with backoff |
service_unavailable | 503 | yes | transient; retry with backoff |
blueprint_busy | 503 | yes | another write to the same Blueprint is in progress. Nothing was written; retry after Retry-After |
The four device codes belong to the CLI sign-in flow (CLI).
authorization_pending and slow_down are not failures — they are the
normal path while a human approves in their browser, which is why both are
retriable: true.
Additions to this registry are announced in the changelog; codes are never removed or renamed within v1.