Errors
Every non-2xx response is an RFC 7807 problem document. Here is the full catalogue of problem types, what triggers each, and whether to retry.
Keepable never returns a bare status code with an opaque body. Every non-2xx
response is an RFC 7807 Problem Details
document with the content type application/problem+json.
The problem shape
{
"type": "https://errors.keepable.co/problems/unprocessable_entity",
"title": "Unprocessable Entity",
"status": 422,
"detail": "nin must be 11 digits",
"instance": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}| Field | Use it for |
|---|---|
type | A stable URI under https://errors.keepable.co/problems/. Branch your error handling on this, not on title or detail. |
title | A short, human-readable summary. Stable per type; safe to surface to operators. |
status | The HTTP status code, repeated in the body for convenience. |
detail | A human-readable explanation specific to this occurrence. May vary; do not pattern-match on it. |
instance | A trace id for this occurrence. Quote it when you contact support: it lets us find your exact request. |
code | Present when one status has several causes worth branching on, e.g. insufficient_funds on a 402, document_in_use on a 409, consent_not_erasable on a 409. Stable, like type. |
Catalogue
The type is the slug shown below, rooted at
https://errors.keepable.co/problems/.
| Status | type slug | Meaning | Retry? |
|---|---|---|---|
| 400 | bad_request | The request was malformed: bad JSON, or a required field missing. | No, fix the request. |
| 401 | unauthorized | Missing, malformed, or revoked credentials. | After fixing the key. |
| 402 | payment_required | The tenant's prepaid wallet lacks the balance for a live send (insufficient_funds). | Yes, after topping up the wallet. |
| 403 | forbidden | Authenticated but missing the scope required by this operation. | No, mint or select a correctly scoped key. |
| 404 | not_found | The resource does not exist (or your tenant cannot see it). | No. |
| 409 | conflict | The request conflicts with existing state, such as an Idempotency-Key reused with a different body or a run withdrawn after delivery. | No, inspect the stable problem code. |
| 413 | request_too_large | The request body exceeds the 32 MiB limit. | No, shrink the payload. |
| 422 | unprocessable_entity | Well-formed but semantically invalid: an 11-digit NIN rule violated, an unknown enum value. | No, fix the data. |
| 429 | rate_limited | Too many requests. | Yes, back off (below). |
| 500 | internal | An unexpected server error. | Yes, with backoff; quote instance if it persists. |
| 501 | not_implemented | The endpoint or capability is not enabled in this environment. | No. |
| 503 | unavailable | A required upstream system did not answer. | Yes, with backoff. |
401 Unauthorized
{ "type": "https://errors.keepable.co/problems/unauthorized", "title": "Unauthorized", "status": 401, "detail": "api key is invalid" }Check that your API key
is sent as Authorization: Bearer <key> and has not been rotated or revoked.
If the key looks right and still 401s, rotate it in Workspace and retry with the
new secret.
403 Forbidden
Your key was not granted the scope the operation requires. Use a key with the right scope.
An address that reaches nobody is not a 403. An unmatched email appears in
the plan run's rejected array without failing the whole run, while an
unmatched NIN or eligible CAC number can be retained. See
Recipients.
409 Conflict
{ "type": "https://errors.keepable.co/problems/conflict", "title": "Conflict", "status": 409, "detail": "this Idempotency-Key was used with a different request body" }Most often an Idempotency-Key collision: you reused a key with a different
request body. Use a fresh key for a genuinely new operation, and the same key
only for retries of the same operation.
429 Rate limited
{ "type": "https://errors.keepable.co/problems/rate_limited", "title": "Too Many Requests", "status": 429, "detail": "rate limit exceeded; retry after 30s" }Back off and retry. Use exponential backoff with jitter, and because every
mutation already carries an Idempotency-Key,
retrying is safe: you will not double-send.
Handling errors well
Branch on type, never on title or detail. The type URI is the stable
contract; the human strings can change between versions.
Retry only 429, 503 and 5xx, with exponential backoff and jitter. 4xx
errors other than 429 are your bug to fix: retrying them unchanged just
repeats the failure.
Log instance on every failure. It is the trace id that lets Partner
Engineering find your exact request when you escalate.
A typed guard keeps the handling in one place:
class KeepableError extends Error {
constructor(
readonly type: string,
readonly status: number,
readonly detail: string,
readonly instance?: string,
) {
super(`${status} ${type}: ${detail}`);
}
}
async function call(res: Response) {
if (res.ok) return res.json();
const p = await res.json(); // application/problem+json
throw new KeepableError(p.type, p.status, p.detail ?? "", p.instance);
}