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"
}| 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 |
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 |
429 | Rate limited | Back off and retry — see Rate limits |
5xx | Server error | Retry 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.messageas 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
timestampfield. 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.