CAPI Docs
Get an API token

Error Handling

A short reference on the error envelope is in Errors. This guide goes deeper: which requests are safe to retry, and what a production-grade handler looks like.

The error envelope#

Always branch on detail.code, never on the human-readable message (messages may change; codes are stable). Group codes by how your client should react:

  • Fix the credentialINVALID_TOKEN, TOKEN_EXPIRED, MISSING_AUTH_HEADER. Don't retry; surface to the operator.
  • Fix the requestINSUFFICIENT_SCOPE (use a read_write token), 422 validation. Don't retry unchanged.
  • Top upinsufficient_balance (402). Don't retry until credits are added.
  • Transient429 and 5xx. Retry with backoff.

Idempotency & retries#

Only retry requests that are safe to repeat. The key distinction is whether a duplicate has a side effect:

RequestSafe to retry?Why
GET /companies/{id}YesRead-only.
POST /companies/searchYesRead-only despite the POST verb.
GET /creditsYesRead-only.
GET /exports/{id}YesRead-only status poll.
POST /exports/{id}/downloadYesGenerates a fresh presigned URL; no charge.
POST /exportsWith careCreates a job + charges credits. A blind retry can create a second export. Only retry on a network error where you never received a response; on a 5xx, check GET /exports first.

A production error handler#

This handler classifies the error, retries only transient failures with jittered backoff, and refuses to retry charged operations blindly:

async function request(url, init, { retryable = true, max = 4 } = {}) {
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(url, init);
    } catch (networkErr) {
      // No response received — safe to retry even charged calls.
      if (attempt >= max) throw networkErr;
      await sleep(backoff(attempt));
      continue;
    }
    if (res.ok) return res.json();

    const { detail = {} } = await res.json().catch(() => ({}));
    const transient = res.status === 429 || res.status >= 500;
    if (transient && retryable && attempt < max) {
      const ra = Number(res.headers.get("Retry-After"));
      await sleep(ra ? ra * 1000 : backoff(attempt));
      continue;
    }
    throw Object.assign(new Error(detail.message || `HTTP ${res.status}`), {
      code: detail.code, status: res.status,
    });
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (n) => (2 ** n * 1000) * (1 + Math.random() * 0.3);

Call POST /exports with retryable: false so a 5xx doesn't silently create a duplicate charged job.