Errors
Every error response uses the same JSON envelope:
{
"error": {
"code": "validation_error",
"message": "url is required",
"correlation_id": "8c8a1b80-6c97-4f5e-8d0a-3a25d1e8e4c1"
}
}
Status to code mapping
| HTTP status | error.code | Meaning |
|---|---|---|
| 400 | validation_error | The request body, query, or headers failed validation |
| 401 | unauthorized | The token is missing, malformed, expired, or revoked |
| 403 | forbidden | The token is valid but lacks permission for this resource |
| 404 | not_found | The resource does not exist (or the caller cannot see it) |
| 409 | conflict | The request conflicts with current state (e.g. duplicate name) |
| 429 | rate_limited | A rate limit was exceeded; see Rate limits |
| 503 | plan_limit_exceeded | A plan quota or limit was reached. The body includes an upgrade.url link |
| 5xx | internal_error | Something failed on our side. Retry with backoff and report correlation_id |
Plan-limit responses
When error.code = "plan_limit_exceeded", the body has an additional field:
{
"error": {
"code": "plan_limit_exceeded",
"message": "Workspace bookmark limit reached on the Free plan.",
"correlation_id": "...",
"upgrade": {"url": "https://not24get.me/billing/upgrade"}
}
}
Surface the upgrade.url to the user; that is the canonical place to upgrade.
Always log correlation_id
If you open a support ticket about a failed request, include the correlation_id from the response (or from the X-Correlation-ID response header). It lets us find the exact server-side trace for that call.
Client pseudocode
async function callApi<T>(req: Request): Promise<T> {
const res = await fetch(req);
if (res.ok) return res.json() as Promise<T>;
const body = await res.json().catch(() => ({}));
const err = body?.error ?? {};
throw Object.assign(new Error(err.message ?? res.statusText), {
status: res.status,
code: err.code,
correlationId: err.correlation_id,
});
}