Skip to content
For developers

Errors

The ActuallyCare error envelope, HTTP status codes, and when (and when not) to retry.

Every error from the API comes back in the same envelope — success flips to false and an error object explains what happened. An illustrative example:

{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Client not found."
  },
  "timestamp": "2026-06-11T18:24:09.123Z"
}
FieldWhat it is
successAlways false on errors
error.codeMachine-readable code in SCREAMING_SNAKE_CASE (like NOT_FOUND or VALIDATION_ERROR) — stable, safe to branch on
error.messageHuman-readable explanation — wording can change, so never string-match it in code
error.detailsOptional extra context: an array of per-field problems on validation errors, or an object (for example locked_until on an account lockout)
errorId, correlationIdOptional identifiers on some errors — include them when contacting support so the failure can be traced
timestampServer time of the failure

Login failures return stable snake-case codes: INVALID_CREDENTIALS (401 — unknown user or wrong password), ACCOUNT_DISABLED (401), ACCOUNT_SUSPENDED (403), and ACCOUNT_LOCKED (423, with error.details.locked_until). One narrower deviation remains: token-layer failures — a missing or expired bearer token, or an invalid API key — still return class-style codes like UnauthorizedError. Branch on the HTTP status for those until they're normalized.

HTTP status codes#

StatusMeaningWhat to do
200SuccessRead data from the envelope
201CreatedRead data for the new record
400Bad request — malformed JSON or invalid parametersFix the request; check error.message
401Not authenticatedCheck the X-API-Key header or token — see Authentication
403Authenticated but not allowedA permissions problem (role, team, or key scope) — retrying won't help. Also returned as ACCOUNT_NOT_ACTIVE when a waitlisted account isn't approved yet, and ACCOUNT_SUSPENDED for suspended accounts
404Not foundCheck the ID (all IDs are UUIDs) and that the record belongs to your team
409ConflictThe request clashes with current state (a duplicate, or a stale update) — re-fetch and decide deliberately
423Account lockedReturned as ACCOUNT_LOCKED. Five consecutive failed logins lock the account for 30 minutes; error.details.locked_until says when it reopens. Stop retrying — see Authentication
429Rate limitedBack off and retry — see Rate limits
5xxServer errorRetry with backoff; check status.actuallycare.com if it persists

Retry on 429 and 5xx — and nothing else#

A 429 or 5xx is transient: waiting and retrying usually succeeds. Every other 4xx is deterministic — the same request will fail the same way, so retrying just burns rate limit. Fix the request instead.

Use exponential backoff with a little jitter:

async function withRetry(makeRequest, maxRetries = 3) {
  for (let attempt = 0; ; attempt++) {
    const res = await makeRequest();
    if (res.ok) return res.json();
 
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt === maxRetries) {
      const body = await res.json().catch(() => null);
      throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
    }
 
    // 1s, 2s, 4s... plus jitter so parallel clients don't retry in lockstep
    const delay = 2 ** attempt * 1000 + Math.random() * 250;
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}
 
const listings = await withRetry(() =>
  fetch("https://api.actuallycare.com/v1/listings", {
    headers: { "X-API-Key": process.env.ACTUALLYCARE_API_KEY },
  })
);

A 429 in practice#

Rate-limited responses use the standard envelope with the code RATE_LIMIT_EXCEEDED, plus RateLimit-* and Retry-After headers that tell you exactly when to come back:

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests, please try again later.",
    "errorId": "err_8f3a1c"
  }
}

Branch on the status (or error.code), wait out Retry-After (seconds) or RateLimit-Reset, and retry. The errorId is a server-side trace id — include it if you contact support about a stuck limit. Guarding res.json() on non-2xx responses is still good hygiene (proxies and load balancers can produce non-JSON bodies), but the API itself answers 429 in JSON.

The current limits, and a retry snippet that reads RateLimit-Reset instead of guessing, are on the Rate limits page.

Debugging tips#

  • Start with the HTTP status, then error.code. The code is the stable, machine-readable answer to "what went wrong" — branch on it, not on the message.
  • Read error.message as a human. It usually names the exact field or rule that failed.
  • Most 401s are credential problems. A missing header, a typo, or a revoked or expired key — walk through Authentication before suspecting anything else.
  • On 429s, check the RateLimit-* headers. They tell you your limit, what's left, and how many seconds until the window resets.
  • Use the timestamp field. It's the server's clock at the moment of failure — handy for lining errors up with your own logs, and worth including when you contact support.
  • On 400s, check the request body against the reference. Required fields and types for every endpoint are in the API reference.