Keepable docs
Foundations

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"
}
FieldUse it for
typeA stable URI under https://errors.keepable.co/problems/. Branch your error handling on this, not on title or detail.
titleA short, human-readable summary. Stable per type; safe to surface to operators.
statusThe HTTP status code, repeated in the body for convenience.
detailA human-readable explanation specific to this occurrence. May vary; do not pattern-match on it.
instanceA 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/.

Statustype slugMeaningRetry?
400bad_requestThe request was malformed: bad JSON, a missing required field, an empty parts array.No, fix the request.
401unauthorizedMissing, malformed, or revoked credentials.After fixing the key.
402payment_requiredThe tenant's prepaid wallet lacks the balance for a live send (insufficient_funds).Yes, after topping up the wallet.
403forbiddenAuthenticated but not permitted: a missing scope, or the recipient is not reachable on Keepable.No, see below.
404not_foundThe resource does not exist (or your tenant cannot see it).No.
409conflictThe request conflicts with existing state: a duplicate TIN, or an Idempotency-Key reused with a different body.No, see Idempotency.
413request_too_largeThe request body exceeds the 32 MiB limit.No, shrink the payload.
422unprocessable_entityWell-formed but semantically invalid: an 11-digit NIN rule violated, an unknown enum value.No, fix the data.
429rate_limitedToo many requests.Yes, back off (below).
500internalAn unexpected server error.Yes, with backoff; quote instance if it persists.
501not_implementedThe endpoint or capability is not enabled in this environment.No.

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; a rotated key's old secret stops working immediately. If the key looks right and still 401s, rotate it from the portal and retry with the new secret.

403 Forbidden

Two distinct causes share this status, and detail tells them apart:

  • Missing scope: your key was not granted the scope the operation requires. Use a key 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.

A typed error guard
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);
}