Keepable
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.
codePresent 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/.

Statustype slugMeaningRetry?
400bad_requestThe request was malformed: bad JSON, or a required field missing.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 a restricted file you are not a reader of.No, see below.
404not_foundThe resource does not exist (or your tenant cannot see it).No.
409conflictThe request conflicts with existing state: an Idempotency-Key reused with a different body, a document something still references (document_in_use), or a consent that cannot be erased (consent_not_erasable).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.
503unavailableA system this operation reaches into did not answer: your work directory or work storage. Distinct from 500, which says the fault is ours, and from 409, which says nothing is connected yet.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; 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.
  • Not a reader: the file is restricted and you are not one of its participants or named readers. No key scope overrides that.

An address that reaches nobody is not a 403. An unmatched email comes back in the send's own rejected array, by index, without failing the batch, and an unmatched NIN or CAC number is retained rather than refused. 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);
}

On this page