# Errors

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

<!-- Source: https://docs.actuallycare.com/api/errors -->

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:

```json
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Client not found."
  },
  "timestamp": "2026-06-11T18:24:09.123Z"
}
```

| Field | What it is |
| --- | --- |
| `success` | Always `false` on errors |
| `error.code` | Machine-readable code in SCREAMING_SNAKE_CASE (like `NOT_FOUND` or `VALIDATION_ERROR`) — stable, safe to branch on |
| `error.message` | Human-readable explanation — wording can change, so never string-match it in code |
| `error.details` | Optional extra context: an array of per-field problems on validation errors, or an object (for example `locked_until` on an account lockout) |
| `errorId`, `correlationId` | Optional identifiers on some errors — include them when contacting support so the failure can be traced |
| `timestamp` | Server 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

| Status | Meaning | What to do |
| --- | --- | --- |
| `200` | Success | Read `data` from the envelope |
| `201` | Created | Read `data` for the new record |
| `400` | Bad request — malformed JSON or invalid parameters | Fix the request; check `error.message` |
| `401` | Not authenticated | Check the `X-API-Key` header or token — see [Authentication](/api/authentication) |
| `403` | Authenticated but not allowed | A 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 |
| `404` | Not found | Check the ID (all IDs are UUIDs) and that the record belongs to your team |
| `409` | Conflict | The request clashes with current state (a duplicate, or a stale update) — re-fetch and decide deliberately |
| `423` | Account locked | Returned 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](/api/authentication) |
| `429` | Rate limited | Back off and retry — see [Rate limits](/api/rate-limits) |
| `5xx` | Server error | Retry with backoff; check [status.actuallycare.com](https://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:

```javascript
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:

```json
{
  "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](/api/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](/api/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](/api/reference).
