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"
}
}codeis stable. The server may add new codes without notice, but existing codes do not change spelling or status.messageis human-readable; safe to show to end users but may change between releases.detailsis a free-form object carrying context for that specific code. Always optional; do not depend on its presence.requestIdechoes theX-Request-IDheader 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.
| Code | HTTP status | When you see it |
|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed, or invalid Authorization header. Re-authenticate. |
FORBIDDEN | 403 | Authenticated but the caller lacks permission for this resource or action. |
PUBLIC_KEY_WRITE_FORBIDDEN | 403 | Public scope key (sl_live_pub_*) cannot use proxy, ingest, or OTLP endpoints. Use a full-scope key. |
ORGANIZATION_NOT_FOUND | 404 | The active workspace context could not be resolved from the auth token. |
PROJECT_NOT_FOUND | 404 | The project id supplied does not exist in the active workspace. |
NOT_FOUND | 404 | The requested resource (trace, evaluator, share, etc.) does not exist or was deleted. |
CONFLICT | 409 | The write conflicts with current state. Refetch and retry, or surface the conflict to the user. |
VALIDATION_FAILED | 400 | Request body failed validation. The details object names the offending fields. |
INVALID_JSON_BODY | 400 | Request body is not parseable JSON. |
BAD_REQUEST | 400 | Generic 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_KEY | 400 | The Spanlens key has no active provider key registered for this provider. Add one on the Projects & Keys page. |
RATE_LIMIT | 429 | Per-key rate limit exceeded. The Retry-After header and X-RateLimit-* headers carry the remaining quota and reset time. Back off and retry. |
INJECTION_BLOCKED | 422 | The 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_TIMEOUT | 504 | Upstream provider (OpenAI / Anthropic / Gemini) did not respond within the timeout. Safe to retry. |
UPSTREAM_FAILED | 502 | Upstream provider returned an error or the network failed. The details object carries the provider name. |
DECRYPT_FAILED | 503 | Provider key decryption failed. Operator-side configuration drift; the operator should rotate ENCRYPTION_KEY. |
INTERNAL_ERROR | 500 | Unexpected 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).