Errors
Every failure answers with the same JSON shape, whatever caused it. Switch on
error.code, never on the message text — messages are localised and may be
reworded; codes are part of the contract.
The envelope
{
"message": "Validation failed.",
"errors": {
"name": ["The name field is required."]
},
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed.",
"details": {
"fields": {
"name": ["The name field is required."]
}
}
},
"request_id": "483c1c5107978d552c8f634d8b141501"
}error.code— the machine-readable cause. This is what your code branches on.error.details— extra context. Field errors for a422, the required and missing scopes for a403.errors— the flat field map, for form libraries that expect it.request_id— also on theX-Request-Idheader. Quote it in a support request and the whole conversation gets shorter.
There is no `success` field on `/v1`. The status code already says whether it worked; a boolean that restates it is one more thing that can disagree with reality.
The codes
Which are worth retrying
The distinction that matters most in production:
| Status | Retry? | Why |
|---|---|---|
429 | Yes, after Retry-After | Temporary by definition |
500, 502, 503, 504 | Yes, with backoff | Server-side, may pass |
409 IDEMPOTENCY_IN_FLIGHT | Yes, after a short pause | The first attempt is still running |
401 | No | Fix the key |
403 | No | Fix the scopes |
404 | No | The resource does not exist on this account |
422 | No | The request is wrong and will stay wrong |
409 (other) | No | Conflicts with current state; re-read and decide |
Except for `429` and the in-flight `409`, a `4xx` describes something about your request. Retrying it produces the same answer, forever, while consuming your rate budget.
Always send an Idempotency-Key on writes so a retry is safe — see
Idempotency.
401, 403 and 404
These three get confused, and the difference is deliberate.
401 UNAUTHENTICATED— no key, a malformed key, or one that is revoked, expired, or calling from a disallowed address.403 INSUFFICIENT_SCOPE— the key is valid but lacks the scope for this route.error.detailslistsrequired,missingandgranted.404 NOT_FOUND— either the resource does not exist, or it belongs to another account.
Answering `403` would confirm the id exists. Anyone could then discover identifiers by probing. Returning `404` means a resource you cannot reach is indistinguishable from one that was never there.
Validation failures
422 VALIDATION_FAILED is the only code that carries per-field errors:
{
"error": {
"code": "VALIDATION_FAILED",
"details": {
"fields": {
"enabled_events": ["The enabled events field is required."],
"url": ["The url must be a valid URL."]
}
}
}
}Every field error is keyed by the request field, so you can attach them directly to inputs.
Handling them
# -f makes curl exit non-zero on HTTP errors; the body still prints.
curl -fsS -X POST https://api.agentency.com/v1/chatbots \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{}' || echo "request failed"Server errors
A 5xx never contains a stack trace, an SQL statement, a provider response, or
a file path. It carries SERVER_ERROR and a request_id.
That is deliberate — error text is one of the classic ways internals leak — but
it does mean the request_id is the only handle on the incident. Log it.
What's next
- Idempotency — retry without duplicating.
- Rate limits — handling
429properly. - Support — what to include in a report.