Snooplytics API DocsHome

Error Handling

Error envelope, full code list, and the retry strategies that actually work

4 min read

Every 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" }
  ]
}
}
FieldDescription
successAlways false on errors
statusHTTP status code, mirrored from the response line
codeStable machine-readable code — branch on this in your handlers
messageHuman-readable summary — show to end users, do not parse
metaStructured 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

CodeHTTPMeaning
VALIDATION_ERROR400One or more request fields failed validation
AUTHENTICATION_REQUIRED401No credential on a protected route
NO_TOKEN_PROVIDED401Authorization header present but empty
INVALID_OR_EXPIRED_TOKEN401Session or token is malformed, expired, or revoked
INVALID_API_KEY401X-Api-Key value did not verify
USER_NOT_ACTIVE401Credential is valid but the user account is deactivated
INSUFFICIENT_PERMISSIONS403Credential lacks a required scope (projects:write, etc.)
NO_SCOPES_AVAILABLE403Token carried no scopes at all
NO_PERMISSIONS403Session user has no permissions resolved
API_KEY_MISSING_SCOPES403PAT is missing one or more scopes the route requires
FORBIDDEN403Valid credential but wrong tenant — e.g. org-pinned PAT mismatch
MFA_REQUIRED403Organization requires MFA and the user has not completed it
TENANT_CONTEXT_MISSING403Tenant route missing the :organizationId URL segment
TENANT_VIOLATION403Resource belongs to a different organization than the URL
NOT_ORGANIZATION_MEMBER403User is not a member of the requested organization
SUBSCRIPTION_REQUIRED403Action requires an active subscription on the organization
NOT_FOUND404Resource does not exist (or is not visible to this caller)
ALREADY_EXISTS409Duplicate resource — usually a slug or email collision
FAILED_CREATE409Create failed due to a constraint violation
FAILED_UPDATE409Update failed due to a constraint violation
FAILED_DELETE409Delete failed due to a constraint violation
RATE_LIMIT_ERROR429Per-IP or per-endpoint rate limit exceeded
ERROR500Unhandled server error — check the trace id when filing a bug
SERVICE_UNAVAILABLE503A 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);
}
}

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));
  }

}
}

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

Best Practices

  • Branch on code, display message: Code is stable across versions; message is copy that may change.
  • Treat 401 and 403 as distinct: 401 means "reauthenticate"; 403 means "this credential will never work — do not retry."
  • Surface validation errors per field: meta.errors is an array of { field, error, location } objects — render them next to the offending input.
  • Store X-Trace-Id with your error logs: It is the fastest path to a resolution when filing a bug.
  • Do not swallow NOT_FOUND unconditionally: 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 code catch drift faster than end-to-end happy-path tests.

Next Steps

  1. Plan for 429s: Read Rate Limits for backoff and header details.
  2. Lock down scopes: Check Scopes & Permissions to avoid INSUFFICIENT_PERMISSIONS in production.
  3. Configure SSO: If you administer an organization, set up Enterprise SSO.
  4. Browse endpoints: Start integrating from the API Reference.