Error catalog
Every /v1 error response uses one standardized envelope so SDKs and
integrations can handle failures uniformly. The HTTP status code conveys the
broad class; the machine-readable code is the stable contract you switch on.
This envelope and its documented
codevalues are part of the frozen API v1 stability contract: within/v1the envelope shape is stable, documented codes are never removed, and a documented response status code is not dropped from an operation.
Envelope
{
"error": {
"code": "spend_cap_exceeded",
"message": "daily spend cap exceeded",
"details": {
"cap_usd": "100.00",
"spent_usd": "104.20",
"resets_at": "2026-06-22T00:00:00+00:00"
}
}
}
| Field | Type | Notes |
|---|---|---|
error.code | string | Stable, snake_case, machine-readable. Switch on this. Never renamed once published — new conditions get a new code. |
error.message | string | Human-readable, English. May change wording; do not parse it. |
error.details | object | Always present (an object). Carries structured, per-error context; {} when there is none. Field set depends on the code. |
The envelope is produced centrally by one error type
(the error module).
Middleware that runs before extraction (auth, regional write-forwarding) emits
the same shape via error::error_envelope(...). Unmatched routes return the
envelope with code: "not_found" through the router fallback. The regression
suite asserts the shape over live HTTP for representative 400/401/403/404
responses; the 402/429/500 code mappings are
covered by unit tests. The validation_failed (422) code below
covers both list sort/filter validation and broader field-level request
validation via the shared ValidatedJson<T> extractor.
Codes
Each code links to its own section below, which carries the details field
set and what to do about it. The code is the stable contract — switch on
it, never on message.
code | Typical HTTP status | Meaning |
|---|---|---|
bad_request | 400 | Malformed or semantically invalid request. |
unauthorized | 401 | Missing or invalid bearer token. |
key_expired | 401 | The API key has expired (or is not yet valid). |
forbidden | 403 | Authenticated but not permitted (insufficient role). |
missing_scope | 403 | The key lacks a scope required for the operation. |
ip_not_allowed | 403 | Caller IP is outside the org's allow-list. |
org_suspended | 403 | The organization is suspended. |
residency_violation | 403 | Cross-region/residency rule refused the operation. |
policy_blocked | 403 | Org policy blocked the attempted model/action. |
policy_approval_required | 403 | Org policy requires approval before the action. |
content_safety_blocked | 403 | A safety detector blocked the content. |
not_found | 404 | Resource does not exist (or is not visible to the caller). |
conflict | 409 | The operation conflicts with current state (e.g. a duplicate or an invalid state transition). |
validation_failed | 422 | One or more request fields failed validation (an unknown ?sort= field, an out-of-range value, or a body that fails to parse). |
gone | 410 | Resource permanently unavailable (e.g. revoked key). |
payload_too_large | 413 | Request body exceeded the configured limit. |
spend_cap_exceeded | 402 | Daily spend cap reached (or would be, pre-flight). |
chain_budget_exceeded | 402 / 429 | Run-chain budget (cost/depth/tokens) exceeded. |
budget_exceeded | 402 | A configured budget (or per-customer limit) for the period would be exceeded. |
quota_exhausted | 402 / 429 | Prepaid quota is exhausted (402 at ingest; 429 via a rate-limit-policy quota window). |
credits_exhausted | 402 | The end customer holds a live credit pack (doc 100) whose remaining credits cannot cover the charge. |
rate_limited | 429 | Rate limit hit. |
plan_limit_exceeded | 402 | The org consumed its monthly plan entitlement (runs or tokens) on a plan whose overage_policy is block. |
insufficient_cohort_size | 400 | The requested benchmark cohort holds fewer contributing orgs than the k-anonymity floor, so no aggregate can be released. |
policy_expression_error | 500 | A policy rule expression failed to evaluate. |
no_model_passes_eval | 503 | No eligible model passed the eval gate. |
service_unavailable | 503 | Dependency unavailable (e.g. primary down, queue full). |
internal | 500 | Unexpected server error. |
idempotency_key_conflict | 409 | The Idempotency-Key was reused with a different request body. |
idempotency_key_processing | 409 | A request carrying this Idempotency-Key is still in flight. |
session_revoked | 401 | The console session cookie is revoked or expired. |
support_scope_denied | 403 | A support impersonation session attempted an operation outside its granted scope. |
bad_request
HTTP 400. Malformed or semantically invalid request.
details: none ({}).
Fix the request. When the failure is field-level, the API returns validation_failed instead, with per-field detail.
unauthorized
HTTP 401. Missing or invalid bearer token.
details: none ({}).
Present a valid Authorization: Bearer <key>. Emitted by auth middleware before handler extraction.
key_expired
HTTP 401. The API key has expired (or is not yet valid).
details: none ({}).
Mint a replacement key. Expiry is a property of the key, so retrying with the same credential cannot clear it.
forbidden
HTTP 403. Authenticated but not permitted (insufficient role).
details: none ({}).
Use a key whose role covers the operation. Distinct from missing_scope, which names the specific missing grant.
missing_scope
HTTP 403. The key lacks a scope required for the operation.
details: required
Mint a key carrying the scope named in details.required; scopes are fixed at mint time.
ip_not_allowed
HTTP 403. Caller IP is outside the org's allow-list.
details: none ({}).
Call from an allow-listed address, or update the org's IP allow-list.
org_suspended
HTTP 403. The organization is suspended.
details: none ({}).
Suspension is an operator/billing state; no client-side retry clears it.
residency_violation
HTTP 403. Cross-region/residency rule refused the operation.
details: none ({}).
Address the region that holds the data. Residency is refused, never silently satisfied by routing elsewhere.
policy_blocked
HTTP 403. Org policy blocked the attempted model/action.
details: reason, attempted, rule_id
details.rule_id names the deciding rule. Change the request, or amend the policy.
policy_approval_required
HTTP 403. Org policy requires approval before the action.
details: reason, attempted, rule_id
Not a permanent refusal: the action proceeds once approved. details.rule_id names the rule that gated it.
content_safety_blocked
HTTP 403. A safety detector blocked the content.
details: detector, sample
details.detector names the detector that fired; details.sample carries the offending excerpt.
not_found
HTTP 404. Resource does not exist (or is not visible to the caller).
details: none ({}).
Deliberately ambiguous: a resource belonging to another tenant is a 404, not a 403, so the code cannot be used to probe for existence across tenants.
conflict
HTTP 409. The operation conflicts with current state (e.g. a duplicate or an invalid state transition).
details: none ({}).
Re-read the resource and retry against its current state. On idempotent surfaces a same-key replay returns the original result rather than this code.
validation_failed
HTTP 422. One or more request fields failed validation (an unknown ?sort= field, an out-of-range value, or a body that fails to parse).
details: fields (array of { field, code, message })
details.fields carries one entry per offending field; see Field-level validation (422) for the per-field code set.
gone
HTTP 410. Resource permanently unavailable (e.g. revoked key).
details: none ({}).
Permanent by definition — retrying cannot succeed.
payload_too_large
HTTP 413. Request body exceeded the configured limit.
details: none ({}).
Split the payload. Bulk ingest surfaces accept batches; a single oversized body does not become acceptable on retry.
spend_cap_exceeded
HTTP 402. Daily spend cap reached (or would be, pre-flight).
details: cap_usd, spent_usd, resets_at, optional reason: "pre_flight"
details.resets_at is when the cap clears. reason: "pre_flight" means the request was refused before incurring cost, not after.
chain_budget_exceeded
HTTP 402 / 429. Run-chain budget (cost/depth/tokens) exceeded.
details: reason, current, attempted, limit
details.reason says which dimension bound — cost, depth or tokens.
budget_exceeded
HTTP 402. A configured budget (or per-customer limit) for the period would be exceeded.
details: budget_id, scope_kind, limit_usd, spent_usd, period_start, period_end
details.scope_kind discriminates the enforcing layer: org | workflow | deployment | department come from budgets; customer comes from a per-customer limit, and there budget_id is the customer-limit row id.
quota_exhausted
HTTP 402 / 429. Prepaid quota is exhausted (402 at ingest; 429 via a rate-limit-policy quota window).
details: quota fields
The 402 and 429 forms are the same exhaustion seen at different layers; neither is transient within the window.
credits_exhausted
HTTP 402. The end customer holds a live credit pack (doc 100) whose remaining credits cannot cover the charge.
details: available_usd, attempted_usd
Distinct from quota_exhausted, which is the org's prepaid dollar balance. A customer with no live pack is not on the credit model and never sees this code.
rate_limited
HTTP 429. Rate limit hit.
details: retry_after_seconds
Back off for the Retry-After header's interval — see Rate limiting. This is the one refusal in this catalog that a plain retry does clear.
plan_limit_exceeded
HTTP 402. The org consumed its monthly plan entitlement (runs or tokens) on a plan whose overage_policy is block.
details: limit_kind (runs|tokens), limit, used
A 402 and deliberately not a 429: the condition is not transient and retrying cannot clear it — only a plan change or the month rolling over will.
insufficient_cohort_size
HTTP 400. The requested benchmark cohort holds fewer contributing orgs than the k-anonymity floor, so no aggregate can be released.
details: minimum_cohort_size — the floor the cohort fell short of.
Widen the cohort. The floor is a privacy guarantee of the benchmark network, not a permission to be granted: a thinner slice is refused rather than published, and the read re-checks the floor independently of how the cohort was requested.
policy_expression_error
HTTP 500. A policy rule expression failed to evaluate.
details: rule_id
A server-side fault in the rule named by details.rule_id, not a caller error — the request was neither allowed nor cleanly denied.
no_model_passes_eval
HTTP 503. No eligible model passed the eval gate.
details: none ({}).
Routing found no candidate meeting the configured eval bar. Relax the gate or add an eligible model.
service_unavailable
HTTP 503. Dependency unavailable (e.g. primary down, queue full).
details: none ({}).
Transient by intent — retry with backoff.
internal
HTTP 500. Unexpected server error.
details: none ({}).
Quote the x-solvano-trace-id response header when reporting: it is the id that locates this exact request in the service's logs.
idempotency_key_conflict
HTTP 409. The Idempotency-Key was reused with a different request body.
details: none ({}).
The key is bound to the body it first succeeded with, so this is a client bug rather than a race: either reuse the key with the original body to replay the stored response, or use a fresh key for the new request.
idempotency_key_processing
HTTP 409. A request carrying this Idempotency-Key is still in flight.
details: none ({}).
Retry after a short delay. A first call that crashed mid-flight is reclaimed automatically once the reservation ages past SOLVANO_IDEMPOTENCY_INFLIGHT_TAKEOVER_SECS, so this does not persist until a GC sweep.
session_revoked
HTTP 401. The console session cookie is revoked or expired.
details: none ({}).
A console-surface code, not an API-key one — the response also clears the stale cookie. Sign in again. Distinct from unauthorized, which is about a bearer token.
support_scope_denied
HTTP 403. A support impersonation session attempted an operation outside its granted scope.
details: reason, scope, method
The grant's scope is fixed when it is issued and deliberately narrower than the impersonated org's own permissions; widening it needs a new grant, not a retry. Every impersonated request is audited whether allowed or denied.
Rate limiting
On rate_limited (and the auth back-off variant), the response also carries a
Retry-After header in seconds, mirroring details.retry_after_seconds.
Edge enforcement decisions
The internal edge decision endpoint (POST /v1/edge/decision) denies
with these same codes and envelopes — budget_exceeded /
quota_exhausted / spend_cap_exceeded (402) and rate_limited (429) — so the
proxy relays the identical body and status to its caller. An allow is a 200
JSON verdict, never an error envelope. See
docs/user/40-budgets-and-spend-enforcement.md
§"Edge enforcement parity".
Field-level validation (422)
validation_failed carries details.fields, one entry per offending field:
{ "error": { "code": "validation_failed", "message": "validation failed",
"details": { "fields": [
{ "field": "to", "code": "out_of_range",
"message": "date range 366 days exceeds the 90-day maximum" }
] } } }
Each field's code distinguishes parse failures — invalid_json,
invalid_type, invalid_content_type, invalid_body — from semantic ones
such as out_of_range, empty, or too_many, so SDKs can map them to
form/field state. Every endpoint that validates a body emits it through the
same path, so the shape is identical wherever you meet it. Current adopters
include the
bulk run ingest (POST /v1/runs/bulk), the retention-policy upsert
(PUT /v1/orgs/{id}/retention-policy), the audit-log SIEM export window
(GET /v1/orgs/{id}/audit-log/export), and list ?sort= / filter parsing.
Stability contract
codevalues are append-only: a published code is never renamed or repurposed. New failure conditions introduce a newcode.messageis informational and may be reworded; clients must not match on it.detailsmay gain fields; clients should ignore unknown ones.
Out of scope
Browser-facing auth flows under /saml/* and /sso/* (OIDC) are interactive
redirect endpoints, not part of the JSON /v1 API surface, and keep their own
flow-specific response bodies.