Skip to content

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.

codeStatusMeaning
validation400A field was missing, malformed, too large, or otherwise invalid.
invalid_credentials401/auth/login credentials did not match.
insufficient_credit402The wallet can't cover the next batch. Carries quote (a BatchQuote).
project_not_found404No 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_found404The named resource was not found.
topup_not_found404No top-up/redeem with that reference exists for the caller (owner-scoped).
email_taken409Registration: the email already has an account.
project_wrong_state409A pipeline transition was invoked from a state its guard forbids.
no_failed_pages409retry when the project has no failed pages to retry.
retention_max_extensions409Retention extension cap reached. Carries maxExtensions + extensionsUsed.
queue_changed409The queue changed under a reorder (an item started/finished).
idempotency_in_flight409An identical request for the same operation is already being processed; retry shortly.
rate_limited429The 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.