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. |
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, a missing required field, an empty parts array. | No, fix the request. |
| 401 | unauthorized | Missing, malformed, or expired credentials. | After re-authenticating. |
| 403 | forbidden | Authenticated but not permitted: a missing scope, or the recipient is not reachable on Keepable. | No, see below. |
| 404 | not_found | The resource does not exist (or your tenant cannot see it). | No. |
| 409 | conflict | The request conflicts with existing state: a duplicate TIN, or an Idempotency-Key reused with a different body. | No, see Idempotency. |
| 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. |
401 Unauthorized
{ "type": "https://errors.keepable.co/problems/unauthorized", "title": "Unauthorized", "status": 401, "detail": "access token is expired" }Re-run the client-credentials exchange to mint a fresh token, then retry. If a fresh token still 401s, the credential itself is the problem: check it has not been rotated or revoked.
403 Forbidden
Two distinct causes share this status, and detail tells them apart:
- Missing scope: your token was not granted the scope the operation requires. Mint a token with the right scope.
- Recipient unreachable: you tried to deliver to someone who is not on
Keepable and did not set
retention_days. Either match the recipient first or send with a retention window so the item is held for their first login.
409 Conflict
{ "type": "https://errors.keepable.co/problems/conflict", "title": "Conflict", "status": 409, "detail": "a tenant with TIN 12345678-0001 already exists" }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 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.
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);
}