Solvano reference
- Error catalog: every
codean API error response can carry. - Deprecations: routes on a deprecation path, and what a caller sees.
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.
Deprecation & sunset signalling
Solvano gives integrators a machine-readable heads-up when a route is on a
deprecation path, ahead of any future /v2. The signalling is driven by one
in-code registry in
the deprecation module. Sunset follows
RFC 8594; the companion Deprecation
header predates RFC 9745 and still
carries that draft's boolean value (see below).
This process is the exception path for the frozen API v1 stability contract: because a
/v1-breaking change has no override in the CI compatibility gate, a genuine break is deprecated here and shipped under a new version rather than edited into/v1.
Contractual notice periods
Under the versioning & support policy, the notice period before a surface is removed is a contract, not a courtesy. The minimum notice depends on the surface class:
| Surface class | Minimum notice before removal | Where it lands |
|---|---|---|
/v1 API — a path, operation, field, enum value, or parameter | Never removed within /v1. The old shape is kept and a replacement ships under /v2, with a ≥ 12-month overlap during which both are served before the /v1 shape is sunset. | Deprecation/Sunset/Link headers, /v1/meta/deprecations, and the changelog |
Webhook v1 event shapes | Per each event's published Sunset date — once published, a sunset date is never shortened. | headers + the X-Solvano-Event-Version version stamp + the changelog |
| Config / environment variables | ≥ 2 releases: announced (deprecated) in release N, removed no earlier than release N+2, counted in the published platform version (doc 57 §1). | the release notes Operator actions section + docs/releases/ledger.yaml |
Three rules bind these guarantees:
- Headers are the notification channel. A deprecated route/field carries the headers below on every response for its entire notice window — a caller can detect a pending removal programmatically, never only at removal time.
- The changelog is the record. Every deprecation is recorded in the generated changelog at announcement and again at removal, so there is a durable, dated audit trail of the notice.
- A config/env removal is a schema-style contraction. It therefore also obeys
the release-sequencing contract: the deprecation is an
announcement entry in
docs/releases/ledger.yamlin release N, and an automated sequencing check fails if the removal lands earlier than N+2. The one-release-plus overlap is what keeps anN+1 → Nbinary rollback safe without a config rollback. N is the platform version you deploy, so "two releases" is something you can count off your own upgrade history — no separate scheme to map onto.
The notice periods are minimums — a widely-used surface may be given longer, never shorter. Shortening a published notice period is itself a breaking change to this contract and is not permitted.
What a caller sees
When a route is flagged deprecated, every response to it carries:
Deprecation: @<seconds>— the date the deprecation took effect, as the RFC 9745 Structured Fields date: an@followed by seconds since the Unix epoch (e.g.Deprecation: @1784678400for 2026-07-22, resolved at midnight UTC). This is the announcement date;Sunsetis the one to plan against. The same date is available in ISO-8601 form as thesincefield ofGET /v1/meta/deprecations.Sunset: <HTTP-date>— the RFC 7231 IMF-fixdate after which the route may be removed (e.g.Tue, 31 Dec 2030 23:59:59 GMT).Link: <doc-url>; rel="deprecation"— a link to the documentation for the change, and, when a successor exists,Link: </v1/new-route>; rel="successor-version".
The full set of deprecated routes is also available as JSON:
GET /v1/meta/deprecations
[
{
"route": "/v1/old-thing/{id}",
"since": "2026-01-01",
"sunset": "Tue, 31 Dec 2030 23:59:59 GMT",
"replacement": "/v1/new-thing/{id}",
"documentation": "https://docs.solvano.ai/deprecations#old-thing"
}
]
See Active deprecations below for the routes
currently in the registry. Actual /v2 routing is out of scope of this
signalling layer.
Active deprecations
The deprecations currently open under this policy. Each is an
announcement: the surface is still served for its entire notice window, the
signalling headers are stamped now, and removal lands in a later release under
the expand/contract / release-sequencing contract (never sooner than the
published Sunset).
| Surface | Class | Announced | Sunset (published) | Replacement | Migration steps |
|---|---|---|---|---|---|
GET /v1/admin/orgs/{org_id}/webhook-secret | Webhook v1 signing (org-level shared secret) | 2026-07-22 | Fri, 31 Dec 2027 23:59:59 GMT | GET /v1/orgs/{id}/webhooks/{sub_id}/signing (per-subscription v2) | Create a per-subscription signing secret via POST /v1/orgs/{id}/webhooks/{sub_id}/signing/rotate, switch consumers to verify the combined X-Solvano-Signature: t=…,v1=… header, then stop relying on the org-level secret. |
POST /v1/admin/orgs/{org_id}/webhook-secret/rotate | Webhook v1 signing (org-level shared secret) | 2026-07-22 | Fri, 31 Dec 2027 23:59:59 GMT | POST /v1/orgs/{id}/webhooks/{sub_id}/signing/rotate (per-subscription v2) | Rotate the per-subscription secret instead; the org-level rotate becomes a no-op once every subscription has its own secret. |
Outbound webhook v1 delivery (org-level signing, X-Solvano-Signature: v1=…) | Webhook v1 event shapes | 2026-07-22 | Fri, 31 Dec 2027 23:59:59 GMT | Per-subscription v2 signing (X-Solvano-Signature: t=…,v1=…) | Deliveries to a subscription without a per-subscription secret carry Deprecation + Sunset + a rel="deprecation" Link; configure per-subscription signing to move a subscription off the v1 wire format. |
SOLVANO_READ_LIMIT_PER_MINUTE env var (global read-quota knob) | Config / environment variable | 2026-07-22 | Removal no earlier than two releases after the announcement | Per-org org_rate_limits.read_rpm rows (admin rate-limit endpoint) + rate_limit_policies | Set per-org read quotas via the admin rate-limit surface instead of the global env knob; the env var stays as the global fallback for orgs with no row until removal. |
GET /v1/forecast (dry-run) | /v1 API | 2026-07-22 | Wed, 31 Dec 2036 23:59:59 GMT (decade-out) | /v1/capacity/forecasts (scenario-based capacity planning) | Move deployment-scaling ROI questions to the richer scenario-based capacity-planning surface. This is a deprecation-path dry-run — the machinery is proven end-to-end (headers, meta listing, counter, OpenAPI flag) on one low-value legacy read (the crude linear projection) with a decade-out sunset and no removal planned; the route stays fully served. |
The two webhook-secret routes carry these headers and appear in
GET /v1/meta/deprecations; the outbound delivery nudge stamps the same
Deprecation/Sunset/Link headers on each v1 delivery. The
GET /v1/forecast dry-run row is deliberately far-future (a decade out)
and slated for no removal — it exists to exercise the full signalling path
(response headers, the /v1/meta/deprecations listing, the
solvano_deprecated_route_calls_total{route="/v1/forecast"} counter, and the
OpenAPI deprecated: true flag) on a genuinely low-value legacy read without
committing to a /v2, and the regression suite covers it end to end. The
env-var deprecation is a config-class change (no HTTP surface), announced here and in
docs/releases/ledger.yaml; per the config contract it is removed no earlier
than two releases after this announcement.
Webhooks v1 signing
The documentation link on the three webhook rows above points here. They
are one deprecation: the org-level shared signing secret, and the v1=
signature it produces on outbound deliveries. Each subscription can carry its
own secret instead; once it does, its deliveries are signed per subscription
and stop carrying the deprecation headers.
Forecast v1 (linear)
The documentation link on GET /v1/forecast points here. The route
projects a deployment's measured return linearly by team count, with no
diminishing-returns curve. The replacement is scenario-based capacity
planning (/v1/capacity/forecasts). The route stays served, and no removal is
planned.
Not deprecated in this sweep: the server-rendered console
The deprecation sweep considered the server-rendered console
in favour of the single-page
console, but did not. ADR 0006 is
explicit that the server-rendered console "stays as the fallback under the
doc 37 lifecycle" while the SPA matures — it is a deliberate fallback, not a
sunsetting surface. Deprecating it would require a proven-parity + fallback-
retirement decision that ADR 0006 has not taken, so no Deprecation header or
sunset date is attached to those routes here. Revisit when a future ADR
supersedes ADR 0006's fallback designation.
Observability
Calls to deprecated routes are counted and exported on /metrics:
# TYPE solvano_deprecated_route_calls_total counter
solvano_deprecated_route_calls_total{route="/v1/old-thing/{id}"} 12
The label is the route template (via MatchedPath), so cardinality stays
bounded no matter how many distinct ids are called. Operators can alert on this
to find integrators still using a sunsetting route.
What a deprecation guarantees
The Deprecation/Sunset/Link headers, the /v1/meta/deprecations entry,
and the counter metric are not opt-in per route: the registry is read on every
response, so once a route is deprecated every response to it carries the
headers, the listing includes it, and its calls are counted.
Every sunset value is a valid RFC 7231 HTTP-date, so you can parse it rather
than pattern-match it.
How a route is flagged is documented for contributors in Adopting the endpoint conventions.