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 expired credentials.After re-authenticating.
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.
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.

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.

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);
}