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.
{
"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).
| 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 |
SUBSCRIPTION_REQUIRED | 402 | Action requires an active subscription on the organization |
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_VIOLATION | 403 | Resource belongs to a different organization than the URL |
NOT_ORGANIZATION_MEMBER | 403 | User is not a member of the requested 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 |
{
"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.
{
"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.
{
"success": false,
"status": 402,
"code": "SUBSCRIPTION_REQUIRED",
"message": "An active subscription is required",
"meta": {
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"billingCode": "active_subscription_required"
}
}Billing and plan-limit errors carry meta.billingCode — one of active_subscription_required,
feature_not_in_tier, seat_cap_exceeded, or slot_cap_exceeded — so you can branch on the
exact limit without parsing the message. Cap errors also include meta.cap and meta.current.
{
"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.
{
"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.
code, not statusasync 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 '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', '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 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)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.",
'SUBSCRIPTION_REQUIRED': 'This action needs an active subscription.',
'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.';
}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.
code, display message: Code is stable across versions; message is copy that may change.401 and 403 as distinct: 401 means "reauthenticate"; 403 means "this credential will never work — do not retry."meta.errors is an array of { field, error, location } objects — render them next to the offending input.X-Trace-Id with your error logs: It is the fastest path to a resolution when filing a bug.NOT_FOUND unconditionally: For writes, a 404 often means the preceding read was stale. Investigate instead of silently retrying.code catch drift faster than end-to-end happy-path tests.INSUFFICIENT_PERMISSIONS in production.How the API is structured, how authentication works, and how multi-tenant requests are scoped
Sign in, issue a Personal Access Token, and make your first authenticated call
Point any MCP-capable AI client at the hosted MCP server and drive Indexhog over OAuth — no token to paste.
Request headers, response envelope, pagination, and the query conventions shared by every endpoint