API error codes

Every 4xx and 5xx response from the Spanlens server uses one stable shape. Branch your client logic on error.code from the catalog below; surfaceerror.message to your user; log error.requestId for support tickets.

Standard envelope

Every error response carries this exact shape.

HTTP/1.1 4xx Status
X-Request-ID: 018f5dcb-1234-7890-9abc-def012345678
Content-Type: application/json

{
  "error": {
    "code": "PUBLIC_KEY_WRITE_FORBIDDEN",
    "message": "Public scope keys cannot use proxy, ingest, or OTLP endpoints",
    "details": { "scope": "public" },
    "requestId": "018f5dcb-1234-7890-9abc-def012345678"
  }
}
  • code is stable. The server may add new codes without notice, but existing codes do not change spelling or status.
  • message is human-readable; safe to show to end users but may change between releases.
  • details is a free-form object carrying context for that specific code. Always optional; do not depend on its presence.
  • requestId echoes the X-Request-ID header on the same response. Quote it in support tickets so the operator can pull the matching server logs.

Catalog

Current as of this docs build. Generated from the server'sERROR_CODES table inapps/server/src/lib/errors.ts; a contract test fails CI if the catalog and the @spanlens/api-types union drift apart.

CodeHTTP statusWhen you see it
UNAUTHORIZED401Missing, malformed, or invalid Authorization header. Re-authenticate.
FORBIDDEN403Authenticated but the caller lacks permission for this resource or action.
PUBLIC_KEY_WRITE_FORBIDDEN403Public scope key (sl_live_pub_*) cannot use proxy, ingest, or OTLP endpoints. Use a full-scope key.
ORGANIZATION_NOT_FOUND404The active workspace context could not be resolved from the auth token.
PROJECT_NOT_FOUND404The project id supplied does not exist in the active workspace.
NOT_FOUND404The requested resource (trace, evaluator, share, etc.) does not exist or was deleted.
CONFLICT409The write conflicts with current state. Refetch and retry, or surface the conflict to the user.
VALIDATION_FAILED400Request body failed validation. The details object names the offending fields.
INVALID_JSON_BODY400Request body is not parseable JSON.
BAD_REQUEST400Generic 400 fallback for legacy handlers whose error message does not match a more specific shape. New handlers should prefer VALIDATION_FAILED with a details object instead.
NO_PROVIDER_KEY400The Spanlens key has no active provider key registered for this provider. Add one on the Projects & Keys page.
RATE_LIMIT429Per-key rate limit exceeded. The Retry-After header and X-RateLimit-* headers carry the remaining quota and reset time. Back off and retry.
INJECTION_BLOCKED422The proxy detected a prompt-injection attempt in the request body and the project has Spanlens security blocking enabled. The request is well-formed but the policy refused to forward it upstream. Inspect the prompt or disable security blocking on the project.
UPSTREAM_TIMEOUT504Upstream provider (OpenAI / Anthropic / Gemini) did not respond within the timeout. Safe to retry.
UPSTREAM_FAILED502Upstream provider returned an error or the network failed. The details object carries the provider name.
DECRYPT_FAILED503Provider key decryption failed. Operator-side configuration drift; the operator should rotate ENCRYPTION_KEY.
INTERNAL_ERROR500Unexpected server error. The Spanlens operator can grep server logs by the requestId echoed in the envelope.

Client examples

TypeScript (Spanlens SDK)

The SDK auto-unwraps the envelope into a typedSpanlensApiError. With silent: false it throws; with the default silent: true it routes the typed error through the onError callback so observability code keeps user code crash-free.

import { SpanlensClient, SpanlensApiError } from '@spanlens/sdk'

const client = new SpanlensClient({ apiKey: 'sl_live_...', silent: false })

try {
  await client.ingestEvent({ event_type: 'span', /* ... */ })
} catch (err) {
  if (err instanceof SpanlensApiError) {
    if (err.code === 'PUBLIC_KEY_WRITE_FORBIDDEN') {
      // Show an actionable upgrade hint, not the raw message.
      showUpgradeBanner()
    } else {
      reportToSentry(err, { requestId: err.requestId })
    }
  } else {
    // Network failure or non-envelope response — keep your existing handler.
    throw err
  }
}

Raw fetch

const res = await fetch('https://server.spanlens.io/api/v1/foo', {
  headers: { Authorization: 'Bearer ' + apiKey }
})
if (!res.ok) {
  const body = await res.json()
  if (body.error?.code === 'NO_PROVIDER_KEY') {
    // ...
  }
  console.error('spanlens error', body.error?.code, body.error?.requestId)
}

Related: @spanlens/sdk (typed exception details),Direct proxy (rate limit headers also use this envelope on 429).