The envelope
Almost every error is a single-field JSON object:
The error value is a stable machine-readable code. It is what you should branch on — the HTTP status alone often is not specific enough (several distinct conditions all return 409).
Request validation is the exception. A body or query that fails schema validation returns a different, richer shape — see 400 — validation failed below.
Status codes
400 — validation failed
The request did not match the endpoint’s schema. The response carries the value you sent and the specific issues:
error is an array of issues, each with the path to the offending field. data echoes what you submitted, which makes this the one error worth logging in full during development.
data contains your request body verbatim. If you log 400 responses in production, redact them — a validation failure on a request carrying sensitive fields will put those fields in your logs.
401 — unauthenticated
No credential, a malformed one, an unknown key prefix, a key whose secret does not verify, or a revoked key. All four look identical by design — distinguishing them would let an attacker confirm which prefixes exist.
403 — forbidden
Authenticated, but not allowed. Two distinct causes share this shape:
A third variant appears on approval routes:
The request is at a stage requiring a permission you do not hold — typically the HR step, which needs hr:approve.
404 — not found
A cross-organization read returns 404, not 403. Tenant isolation is enforced at the database level, so a record in another organization is not visible at all — the row does not exist as far as your query is concerned.This is deliberate. Returning “forbidden” would confirm the record exists, which is itself a leak. Do not treat a 404 as proof that an ID is invalid; it may simply not be yours.
A scope check against an employee who does not exist also returns 404 rather than 403, for the same reason.
409 — state conflict
The request is well-formed and permitted, but the resource is not in a state that allows it. This is where the specific code matters most:
not_pending is normal under concurrency, not an error in your integration. When two approvers act simultaneously exactly one wins and the other gets this. Treat it as “already done” rather than retrying.
429 — rate limited
600 requests per minute per key on the public API. Standard rate-limit headers accompany the response. Back off and retry.
A declared vendor seam with no credentials configured — TRACES Part-A fetch and DSC signing in the Form 16 pipeline. This is an honest refusal rather than a pretence of success.
Handling errors
Which errors to retry