Errors
When a request fails, the API always answers in the same JSON shape, whatever went wrong. Learn it once and every error — a bad field, an empty wallet, a call at the wrong time — is easy to handle. You never have to parse a different structure per endpoint.
That shape follows a web standard called RFC 9457 (an application/problem+json "problem details" object). Any response that isn't a success (anything that isn't a 2xx) looks like this:
json
{
"type": "https://kenly/errors/project_wrong_state",
"title": "Invalid state transition",
"status": 409,
"detail": "Cannot 'normalize' from state 'Calibrating'. Expected: Created.",
"code": "project_wrong_state"
}Branch on code, not on prose
Of those fields, code is the one to build on. It's a stable, machine-readable string, and it's what you should switch on in your code. title and detail are written for humans and may be reworded at any time — so never try to pattern-match their text. type is always https://kenly/errors/{code} (the base part is published as problemTypeBase in GET /v1/meta), which means code and type always carry the same stable identifier — pick whichever is handier.
javascript
const res = await fetch(url, options);
if (!res.ok) {
const problem = await res.json(); // application/problem+json
switch (problem.code) {
case "insufficient_credit":
return promptTopUp(problem.quote); // typed extension member, see below
case "rate_limited":
return retryAfter(res.headers.get("Retry-After"));
default:
throw new Error(`${problem.code}: ${problem.detail}`);
}
}Typed extension members
Some errors carry a little extra detail, and when they do it rides along as extra fields on the same object — not a different shape you have to detect. For example, a 402 (out of credit) includes a quote telling you what the next batch would cost, and a retention-cap 409 includes maxExtensions and extensionsUsed. Just read those fields straight off the problem object.
Stable codes
Here's the full set of codes you might see, with the HTTP status each comes with and what it means. Build your error handling around the code column.
code | Status | Meaning |
|---|---|---|
validation | 400 | A field was missing, malformed, too large, or otherwise invalid. |
invalid_credentials | 401 | /auth/login credentials did not match. |
insufficient_credit | 402 | The wallet can't cover the next batch. Carries quote (a BatchQuote). |
project_not_found | 404 | No project with that id exists for the caller (owner-scoped; never leaks existence). |
account_not_found / job_not_found / notification_not_found / avatar_not_found / source_not_found / export_not_found / image_not_found | 404 | The named resource was not found. |
topup_not_found | 404 | No top-up/redeem with that reference exists for the caller (owner-scoped). |
email_taken | 409 | Registration: the email already has an account. |
project_wrong_state | 409 | A pipeline transition was invoked from a state its guard forbids. |
no_failed_pages | 409 | retry when the project has no failed pages to retry. |
retention_max_extensions | 409 | Retention extension cap reached. Carries maxExtensions + extensionsUsed. |
queue_changed | 409 | The queue changed under a reorder (an item started/finished). |
idempotency_in_flight | 409 | An identical request for the same operation is already being processed; retry shortly. |
rate_limited | 429 | The per-principal request rate limit was exceeded. Carries Retry-After + RateLimit-* headers. |
We may add new codes over time — that's a safe, non-breaking change (see Versioning) — so always keep a default branch to catch a code you don't recognize yet. What won't change is an existing code's meaning or its status.
Owner-scoping never leaks existence
Everything you touch is owner-scoped: you can only see your own data. So if you ask for a resource that belongs to someone else, you get the exact same 404 as one that doesn't exist — the same for a JWT and for an API key. There's no separate "it exists, but it's not yours" response, which means nobody can probe around to discover other accounts' resources. (The only thing a 403 ever means is "your credential is valid, but it's missing the scope this call needs.")
Source of truth: the error envelope and the code table are single-sourced in the repo at
docs/api-contract.md§1; this page renders it for developers.