Errors

Same envelope, every endpoint.

Every 4xx (and 5xx) response from /api/v1/* uses one envelope. Build your error handling once and stop guessing.

All docs / Errors

Envelope

{
  "error": {
    "code":       "validation_error",
    "message":    "Validation failed.",
    "details":    { "email": ["Already in use."] },
    "request_id": "9f2c5b4d-77b2-4f48-a4d3-8b3f01b8d8f2"
  }
}
  • code — stable string. Branch on this. Never branch on the human-readable message; we may rephrase.
  • message — short, end-user safe summary.
  • details — optional. For validation_error, a dict of field → list of errors. Otherwise null.
  • request_id — paste into support tickets so we can grep server logs in seconds.

Codes you'll see

Code HTTP Meaning
validation_error422Field-level validation failed. details has the per-field reasons.
invalid_credentials400Login email + password didn't match a user.
authentication_failed401Bearer token missing, expired, or revoked. Refresh or log in again.
permission_denied403Authenticated, but not allowed.
subscription_required402Action needs an active subscription. Direct the user to /pricing/.
theme_premium_required403Theme is part of a premium plan; current plan doesn't unlock it.
not_found404Resource missing — or you don't own it (we 404 instead of 403 to avoid id-enumeration).
conflict409Action would violate an invariant (e.g. two live subscriptions at once).
rate_limited429Throttle hit. Honor Retry-After — see Rate limits.
service_unavailable503Upstream integration (Twilio/Firebase/etc.) is down. Retry with backoff.
app_error400Generic business-rule violation. Read message for the specifics.

New codes will appear over time. If you see one we haven't documented, treat it as the closest super-category (e.g. unknown *_required → handle like subscription_required) and tell us.

Client patterns

JavaScript

async function api(url, options) {
  const res = await fetch(url, options);
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    const code = body?.error?.code ?? `http_${res.status}`;
    if (code === "authentication_failed") return refreshAndRetry(url, options);
    if (code === "subscription_required") return goToPricing();
    if (code === "rate_limited") return backoff(res.headers.get("Retry-After"));
    throw new ApiError(code, body?.error?.message);
  }
  return res.json();
}

Python

import requests

class ApiError(Exception):
    def __init__(self, code: str, message: str, request_id: str | None):
        super().__init__(f"[{code}] {message}")
        self.code = code
        self.request_id = request_id

def call(method, url, **kw):
    r = requests.request(method, url, **kw)
    if r.ok:
        return r.json()
    body = (r.json() or {}).get("error", {})
    raise ApiError(
        code=body.get("code") or f"http_{r.status_code}",
        message=body.get("message", r.reason),
        request_id=body.get("request_id"),
    )

Bug? Drop us a note via the contact form with the offending request_id.