Solvano reference

  • Error catalog: every code an 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 code values are part of the frozen API v1 stability contract: within /v1 the 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"
    }
  }
}
FieldTypeNotes
error.codestringStable, snake_case, machine-readable. Switch on this. Never renamed once published — new conditions get a new code.
error.messagestringHuman-readable, English. May change wording; do not parse it.
error.detailsobjectAlways 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.

codeTypical HTTP statusMeaning
bad_request400Malformed or semantically invalid request.
unauthorized401Missing or invalid bearer token.
key_expired401The API key has expired (or is not yet valid).
forbidden403Authenticated but not permitted (insufficient role).
missing_scope403The key lacks a scope required for the operation.
ip_not_allowed403Caller IP is outside the org's allow-list.
org_suspended403The organization is suspended.
residency_violation403Cross-region/residency rule refused the operation.
policy_blocked403Org policy blocked the attempted model/action.
policy_approval_required403Org policy requires approval before the action.
content_safety_blocked403A safety detector blocked the content.
not_found404Resource does not exist (or is not visible to the caller).
conflict409The operation conflicts with current state (e.g. a duplicate or an invalid state transition).
validation_failed422One or more request fields failed validation (an unknown ?sort= field, an out-of-range value, or a body that fails to parse).
gone410Resource permanently unavailable (e.g. revoked key).
payload_too_large413Request body exceeded the configured limit.
spend_cap_exceeded402Daily spend cap reached (or would be, pre-flight).
chain_budget_exceeded402 / 429Run-chain budget (cost/depth/tokens) exceeded.
budget_exceeded402A configured budget (or per-customer limit) for the period would be exceeded.
quota_exhausted402 / 429Prepaid quota is exhausted (402 at ingest; 429 via a rate-limit-policy quota window).
credits_exhausted402The end customer holds a live credit pack (doc 100) whose remaining credits cannot cover the charge.
rate_limited429Rate limit hit.
plan_limit_exceeded402The org consumed its monthly plan entitlement (runs or tokens) on a plan whose overage_policy is block.
insufficient_cohort_size400The requested benchmark cohort holds fewer contributing orgs than the k-anonymity floor, so no aggregate can be released.
policy_expression_error500A policy rule expression failed to evaluate.
no_model_passes_eval503No eligible model passed the eval gate.
service_unavailable503Dependency unavailable (e.g. primary down, queue full).
internal500Unexpected server error.
idempotency_key_conflict409The Idempotency-Key was reused with a different request body.
idempotency_key_processing409A request carrying this Idempotency-Key is still in flight.
session_revoked401The console session cookie is revoked or expired.
support_scope_denied403A 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

  • code values are append-only: a published code is never renamed or repurposed. New failure conditions introduce a new code.
  • message is informational and may be reworded; clients must not match on it.
  • details may 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 classMinimum notice before removalWhere it lands
/v1 API — a path, operation, field, enum value, or parameterNever 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 shapesPer 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.yaml in release N, and an automated sequencing check fails if the removal lands earlier than N+2. The one-release-plus overlap is what keeps an N+1 → N binary 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: @1784678400 for 2026-07-22, resolved at midnight UTC). This is the announcement date; Sunset is the one to plan against. The same date is available in ISO-8601 form as the since field of GET /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).

SurfaceClassAnnouncedSunset (published)ReplacementMigration steps
GET /v1/admin/orgs/{org_id}/webhook-secretWebhook v1 signing (org-level shared secret)2026-07-22Fri, 31 Dec 2027 23:59:59 GMTGET /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/rotateWebhook v1 signing (org-level shared secret)2026-07-22Fri, 31 Dec 2027 23:59:59 GMTPOST /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 shapes2026-07-22Fri, 31 Dec 2027 23:59:59 GMTPer-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 variable2026-07-22Removal no earlier than two releases after the announcementPer-org org_rate_limits.read_rpm rows (admin rate-limit endpoint) + rate_limit_policiesSet 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 API2026-07-22Wed, 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.