Errors
All errors share a single envelope:
{
"error": {
"code": "validation_failed",
"message": "Field 'email' is required",
"details": {
"field": "email"
}
}
}
codeis a machine-readable identifier. Stable across releases.messageis human-readable English. Localised strings are not provided — translate at the consumer.detailsis optional and shape-specific to the error. For validation errors it includes the offending field; for rate-limit errors it includes a numericretry_after.
Standard codes
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | Malformed JSON, unsupported Content-Type, etc. |
| 400 | validation_failed | Body or query failed schema validation. |
| 401 | mtls_required | Client certificate missing/invalid. |
| 401 | invalid_token | JWT malformed or signature invalid. |
| 401 | invalid_audience | aud does not match the environment. |
| 401 | token_expired | exp claim in the past. |
| 401 | token_revoked | jti listed in RevokedTokens. |
| 403 | insufficient_scope | Token lacks the required scope. |
| 403 | insufficient_permission | The person behind the token lacks the permission the endpoint needs; message is the permission slug. |
| 403 | forbidden | Origin shared-secret missing or wrong, or the token resolves to no tenant — message is unknown_organization, no_membership or unknown_workspace. See Authentication. |
| 400 | workspace_required | The organization has several workspaces and X-Workspace-Id was not sent. |
| 404 | not_found | Resource does not exist (or belongs to another workspace). |
| 409 | conflict | State conflict (e.g. idempotency key reused with a different body, unique constraint violation). |
| 422 | unprocessable_entity | Business rule violation (e.g. cannot deactivate a webhook with active retries). |
| 429 | rate_limited | Per-account limit exceeded. Retry-After header included. |
| 500 | internal_error | Unhandled error. Correlation id returned in details.request_id. |
| 503 | service_unavailable | Upstream dependency (S3, DB, AI Gateway) failing. |
Reading details
details is intentionally loose. Common patterns:
- Validation errors —
{ "field": "subscribed_events.0", "issue": "must be a known SystemEvent slug" }. - Rate limiting —
{ "retry_after": 17, "limit": 600, "remaining": 0 }. - Idempotency conflict —
{ "key": "...", "original_status": 201, "original_response_id": "..." }. - Not found across workspace boundary — never leaks the existence of the resource; the response is indistinguishable from a truly missing id.
Correlation
Every response — successful or not — carries a X-Request-Id header. Include it when reporting issues so we can pull the trace from OpenTelemetry.
Retry guidance
| Error class | Retry? |
|---|---|
400, 401, 403, 404, 409, 422 | No — fix the request. |
429 | Yes, after Retry-After seconds. |
500, 503 | Yes, with exponential backoff (start at 1s, cap at 30s, max 5 attempts). |
For idempotent operations (GET, PATCH, DELETE) retrying is always safe. For POST operations always include an X-Idempotency-Key so retries don't double-create.