Error Handling
Error envelope, full code list, and the retry strategies that actually work
4 min readEvery error response uses the same envelope as a success response — only success flips to
false and code carries a machine-readable reason. Branch on code (not the human message) in
your handlers; the code is stable, the message is not.
Error envelope
{
"success": false,
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed for fields: website",
"meta": {
"errors": [
{ "field": "website", "error": "Invalid url", "location": "body" }
]
}
}| Field | Description |
|---|---|
success | Always false on errors |
status | HTTP status code, mirrored from the response line |
code | Stable machine-readable code — branch on this in your handlers |
message | Human-readable summary — show to end users, do not parse |
meta | Structured details: validation errors, required scopes, etc. |
Each entry in meta.errors has field (the offending field path), error (the message), and location (body, query, or params).
Error code reference
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | One or more request fields failed validation |
AUTHENTICATION_REQUIRED | 401 | No credential on a protected route |
NO_TOKEN_PROVIDED | 401 | Authorization header present but empty |
INVALID_OR_EXPIRED_TOKEN | 401 | Session or token is malformed, expired, or revoked |
INVALID_API_KEY | 401 | X-Api-Key value did not verify |
USER_NOT_ACTIVE | 401 | Credential is valid but the user account is deactivated |
INSUFFICIENT_PERMISSIONS | 403 | Credential lacks a required scope (projects:write, etc.) |
NO_SCOPES_AVAILABLE | 403 | Token carried no scopes at all |
NO_PERMISSIONS | 403 | Session user has no permissions resolved |
API_KEY_MISSING_SCOPES | 403 | PAT is missing one or more scopes the route requires |
FORBIDDEN | 403 | Valid credential but wrong tenant — e.g. org-pinned PAT mismatch |
MFA_REQUIRED | 403 | Organization requires MFA and the user has not completed it |
TENANT_CONTEXT_MISSING | 403 | Tenant route missing the :organizationId URL segment |
TENANT_VIOLATION | 403 | Resource belongs to a different organization than the URL |
NOT_ORGANIZATION_MEMBER | 403 | User is not a member of the requested organization |
SUBSCRIPTION_REQUIRED | 403 | Action requires an active subscription on the organization |
NOT_FOUND | 404 | Resource does not exist (or is not visible to this caller) |
ALREADY_EXISTS | 409 | Duplicate resource — usually a slug or email collision |
FAILED_CREATE | 409 | Create failed due to a constraint violation |
FAILED_UPDATE | 409 | Update failed due to a constraint violation |
FAILED_DELETE | 409 | Delete failed due to a constraint violation |
RATE_LIMIT_ERROR | 429 | Per-IP or per-endpoint rate limit exceeded |
ERROR | 500 | Unhandled server error — check the trace id when filing a bug |
SERVICE_UNAVAILABLE | 503 | A required dependency is temporarily unavailable |
Common error examples
Validation error
{
"success": false,
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed for fields: name, website, slug",
"meta": {
"errors": [
{ "field": "name", "error": "Required", "location": "body" },
{ "field": "website", "error": "Invalid url", "location": "body" },
{ "field": "slug", "error": "The slug must be unique", "location": "body" }
]
}
}Iterate meta.errors to surface per-field messages in your UI.
Missing authentication
{
"success": false,
"status": 401,
"code": "AUTHENTICATION_REQUIRED",
"message": "Authentication required",
"meta": {}
}Send a session cookie, an X-Api-Key header, or an Authorization: Bearer ... token.
Missing tenant context
{
"success": false,
"status": 403,
"code": "TENANT_CONTEXT_MISSING",
"message": "Organization context required",
"meta": {}
}Include the organization id in the URL path (e.g. /api/user/organizations/{organizationId}/projects).
Insufficient scope
{
"success": false,
"status": 403,
"code": "INSUFFICIENT_PERMISSIONS",
"message": "Insufficient permissions. Required: projects:write",
"meta": {}
}Use a credential that has projects:write, or issue a new PAT with the missing scope.
Rate limit
{
"success": false,
"status": 429,
"code": "RATE_LIMIT_ERROR",
"message": "Too many requests from this IP, please try again later.",
"meta": {
"message": "Too many requests from this IP, please try again later."
}
}Read the Retry-After header and back off. See Rate Limits.
Handling errors in code
Branch on code, not status
async function call(url, options) {
const res = await fetch(url, options);
const envelope = await res.json();
if (envelope.success) return envelope.data;
switch (envelope.code) {
case 'VALIDATION_ERROR':
throw new ValidationError(envelope.meta?.errors ?? []);
case 'AUTHENTICATION_REQUIRED':
case 'INVALID_OR_EXPIRED_TOKEN':
case 'INVALID_API_KEY':
throw new AuthError(envelope.message);
case 'INSUFFICIENT_PERMISSIONS':
case 'FORBIDDEN':
case 'TENANT_CONTEXT_MISSING':
case 'NOT_ORGANIZATION_MEMBER':
throw new PermissionError(envelope.message);
case 'NOT_FOUND':
return null;
case 'RATE_LIMIT_ERROR':
throw new RateLimitError(Number(res.headers.get('Retry-After')) || 60);
default:
throw new ApiError(envelope.code, envelope.message, envelope.meta);
}
}def call(method, url, **kwargs):
res = session.request(method, url, **kwargs)
envelope = res.json()
if envelope.get('success'):
return envelope.get('data')
code = envelope.get('code')
if code == 'VALIDATION_ERROR':
raise ValidationError(envelope.get('meta', {}).get('errors', []))
if code in {'AUTHENTICATION_REQUIRED', 'INVALID_OR_EXPIRED_TOKEN', 'INVALID_API_KEY'}:
raise AuthError(envelope.get('message'))
if code in {'INSUFFICIENT_PERMISSIONS', 'FORBIDDEN', 'TENANT_CONTEXT_MISSING', 'NOT_ORGANIZATION_MEMBER'}:
raise PermissionError(envelope.get('message'))
if code == 'NOT_FOUND':
return None
if code == 'RATE_LIMIT_ERROR':
raise RateLimitError(int(res.headers.get('Retry-After', 60)))
raise ApiError(code, envelope.get('message'), envelope.get('meta'))Retry with exponential backoff
Retry only idempotent requests on 429 and transient 5xx. Never retry a 4xx (except 429) — retries on 401, 403, and 404 will always fail.
async function withRetry(fn, { retries = 3 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
const retriable = err.code === 'RATE_LIMIT_ERROR' || (err.status >= 500 && err.status < 600);
if (!retriable || attempt === retries) throw err;
const wait = err.retryAfter ?? Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
}import time
def with_retry(fn, retries=3):
for attempt in range(retries + 1):
try:
return fn()
except (RateLimitError, ServerError) as err:
if attempt == retries:
raise
wait = getattr(err, 'retry_after', pow(2, attempt))
time.sleep(wait)User-facing messages
Translate error codes to friendly copy at the UI boundary — never pass the raw message straight through to end users in production.
const MESSAGES = {
VALIDATION_ERROR: 'Please check the highlighted fields and try again.',
AUTHENTICATION_REQUIRED: 'Your session has expired. Sign in again to continue.',
INVALID_OR_EXPIRED_TOKEN:'Your session has expired. Sign in again to continue.',
INSUFFICIENT_PERMISSIONS:"You don't have permission to do that.",
TENANT_CONTEXT_MISSING: 'Please select an organization before continuing.',
NOT_FOUND: "We couldn't find what you were looking for.",
ALREADY_EXISTS: 'That name is already taken.',
RATE_LIMIT_ERROR: "You're doing that too fast. Please wait a moment.",
ERROR: 'Something went wrong on our end. Please try again shortly.',
};
function friendly(envelope) {
return MESSAGES[envelope.code] ?? 'An unexpected error occurred.';
}Debugging tips
Every response carries a trace id. The X-Trace-Id response header identifies the exact
request — quote it when filing a bug and support can find it instantly.
Never log full error payloads that contain request bodies. Validation errors reference the offending field names; the user-supplied values may be sensitive.
Best Practices
- Branch on
code, displaymessage: Code is stable across versions; message is copy that may change. - Treat
401and403as distinct:401means "reauthenticate";403means "this credential will never work — do not retry." - Surface validation errors per field:
meta.errorsis an array of{ field, error, location }objects — render them next to the offending input. - Store
X-Trace-Idwith your error logs: It is the fastest path to a resolution when filing a bug. - Do not swallow
NOT_FOUNDunconditionally: For writes, a 404 often means the preceding read was stale. Investigate instead of silently retrying. - Write tests for the failure paths: Contract tests that assert on
codecatch drift faster than end-to-end happy-path tests.
Next Steps
- Plan for 429s: Read Rate Limits for backoff and header details.
- Lock down scopes: Check Scopes & Permissions to avoid
INSUFFICIENT_PERMISSIONSin production. - Configure SSO: If you administer an organization, set up Enterprise SSO.
- Browse endpoints: Start integrating from the API Reference.
Related Pages
Introduction
How the API is structured, how authentication works, and how multi-tenant requests are scoped
Quick Start
Sign in, issue a Personal Access Token, and make your first authenticated call
API Structure
Request headers, response envelope, pagination, and the query conventions shared by every endpoint
Rate Limits
Per-IP and per-endpoint request limits, the headers that expose them, and how to retry safely