Every error response uses the same envelope:
{
"error": {
"code": "validation_failed",
"message": "Validation failed: title is required.",
"details": [{ "path": "title", "message": "Required" }],
"request_id": "req_3727ca7b4f0fff3c"
}
}code- stable, machine-readable. Switch on this.message- human-readable summary. Safe to surface to operators; not always safe to surface to end users verbatim.details- optional, code-specific structure (validation paths, required scopes, rate-limit info).request_id- also returned as theX-Request-Idheader. Quote it in support tickets and we can pull the exact request from our logs.
Every response - success or error - carries X-Request-Id. Logging this header on the client side makes triage trivial.
Codes
HTTP | Code | When |
|---|---|---|
400 |
| Body or query failed validation. |
401 |
| Missing or invalid bearer token. |
403 |
| Key doesn't have the scope this endpoint requires. |
403 |
| Authenticated but blocked (plan gating, deleted resource, etc.). |
404 |
| Resource doesn't exist or has been deleted. |
409 |
| Semantic conflict (e.g. closing an already-closed live chat). |
410 |
| You passed a v1 key to a v2 endpoint. |
422 |
| Well-formed request we still can't process (e.g. malformed cursor). |
429 |
| Too many requests. See Rate limits. |
500 |
| Our problem. Retry with backoff; if it persists, share the |
Details shapes
The details field varies by code. The shapes you'll actually inspect:
validation_failed
Array of per-field reasons:
"details": [
{ "path": "title", "message": "Required" },
{ "path": "tags.0", "message": "Expected string, received number" }
]
path is a dotted path through the request body. Use it to highlight the offending field in your UI.
scope_required
"details": { "required": ["threads:write"], "granted": ["threads:read"] }
Tells you exactly which scope to add to the key.
rate_limited
"details": { "limit": 60, "window": "1m" }
The response also includes a Retry-After header - prefer that for retry timing.
Handling errors
type ApiError = {
error: {
code: string;
message: string;
details?: unknown;
request_id: string;
};
};
const res = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) {
const body = (await res.json()) as ApiError;
switch (body.error.code) {
case "rate_limited": {
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
await new Promise((r) => setTimeout(r, retryAfter * 1000));
// retry
break;
}
case "scope_required":
// tell the operator to widen the key's scopes
break;
case "validation_failed":
// surface body.error.details to the user
break;
case "unsupported_key_version":
// you passed a v1 key - mint a v2 one
break;
default:
console.error(
"Productlane error",
body.error.code,
body.error.request_id,
);
}
}Retry strategy
Code | Retry? |
|---|---|
| Yes - honour |
| Yes - exponential backoff (1s, 2s, 4s, …), give up after a few attempts. |
| Sometimes - only if your retry resolves the conflict (e.g. re-fetch then re-submit). |
Everything else | No. A retry will fail the same way. Fix the request. |
