Errors are JSON. The HTTP status is authoritative; the error code is the stable, machine-readable name to branch on. Human-readable message strings may change without notice — never match on them.
Envelope
{
"error": "invalid_payload",
"message": "Request body failed validation.",
"request_id": "req_8fK2x9aLp0qR",
"details": {
"formErrors": [],
"fieldErrors": {
"name": ["Required"],
"status": ["Invalid enum value. Expected 'draft' | 'active', got 'live'."]
}
}
}
error — stable machine code. Branch on this.
message — human-readable. For logs and UI; do not parse.
request_id — echo of the X-Request-Id response header. Include it in support tickets.
details — present when the error has structured context (validation field errors, conflicting state, etc.). Shape varies per code.
For validation failures (invalid_payload, invalid_query), details is { "formErrors": [...], "fieldErrors": { "<field>": ["message", ...] } }. fieldErrors keys are the offending fields, each mapped to an array of messages; formErrors holds errors that aren’t tied to a single field.
Timestamps throughout the API are ISO-8601 with an explicit +00:00 UTC offset (for example "2026-06-09T22:50:58.806+00:00"), not a trailing Z.
Always log request_id and the full response body when a request fails. The request id lets support trace a single call across the stack in under a second.
Authentication
| Status | Code | When you see it | Recovery |
|---|
| 401 | missing_api_key | No Authorization header on the request. | Add Authorization: Bearer aw_live_…. |
| 401 | invalid_api_key | Header present, value not recognized. | Typo, trailing whitespace, or wrong environment. Re-check the value. |
| 401 | api_key_revoked | Key was revoked in the dashboard. | Mint a fresh key. |
| 401 | api_key_expired | Key passed its expires_at. | Mint a fresh key or extend the expiration. |
| 403 | api_key_paused | Key exists but is paused. | Resume it in the dashboard. |
| 403 | forbidden | Action not permitted for this key. Typically a cross-org access attempt. | Check that the resource belongs to the same org as the key. |
See Authentication for key lifecycle.
Validation
| Status | Code | When you see it | Recovery |
|---|
| 400 | invalid_json | Request body wasn’t valid JSON. | Fix the JSON — usually a trailing comma, unquoted key, or wrong content-type. |
| 400 | invalid_payload | Body parsed but failed schema validation. details.fieldErrors maps each field to its messages. | Fix the listed fields. |
| 400 | invalid_query | Query string failed validation. details.fieldErrors maps each field to its messages. | Fix the query parameters. |
| 400 | invalid_id | A path or query id isn’t a valid UUID. | Awardee ids are bare UUIDs. No prefixes. |
| 400 | invalid_cursor | Pagination cursor is malformed or tampered. | Pass next_cursor verbatim from the prior response, URL-encoded. |
| 422 | unprocessable | Well-formed request that cannot be applied — for example, referencing a resource that doesn’t exist or belongs to another org (message: “A referenced resource does not exist.”). | Fix the offending reference. |
| 422 | insufficient_credits | The action would consume credits the org doesn’t have. | Top up credits, or remove the credit-consuming feature flag from the request. |
Not found and gone
| Status | Code | When you see it | Recovery |
|---|
| 404 | not_found | Resource doesn’t exist, or it belongs to a different org. | Confirm the id and the key’s org. Cross-org access returns 404 by design, not 403. |
| 410 | gone | Resource existed but has been permanently deleted. The id will never resolve again. | Stop referencing it. |
Conflict
| Status | Code | When you see it | Recovery |
|---|
| 409 | conflict | Generic state conflict — e.g. updating a resource that was modified concurrently, or violating a uniqueness constraint. details describes the conflict. | Re-fetch the resource and retry with the latest state. |
| 409 | category_not_empty | Deleting an article category that still has child categories or articles. | Move the children first, or pass ?cascade=true to delete them too. |
| 409 | idempotency_conflict | Idempotency-Key reused with a different request body inside the 24h window. | Either generate a new key, or send the same body. See Idempotency. |
Rate limits
| Status | Code | When you see it | Recovery |
|---|
| 429 | rate_limited | The organization’s minute or hour cap was hit (one shared bucket across all credentials). | Honor the Retry-After header, add jitter, back off exponentially. See Rate limits. |
Server errors
| Status | Code | When you see it | Recovery |
|---|
| 500 | internal_error | Unhandled exception on our side. Always a bug. | Safe to retry. If it persists, contact support with the request_id. |
| 503 | service_unavailable | Upstream dependency (database, queue, AI provider) is degraded. | Retry with exponential backoff. Our status page reflects sustained incidents. |
5xx responses are always retry-safe when paired with an Idempotency-Key on POSTs. GET, PATCH, PUT, and DELETE are naturally idempotent.
Worked examples
A validation error on POST /chatbots:
POST /v1/chatbots HTTP/1.1
Authorization: Bearer aw_live_4f8a3c7e2d1b9a5c6f8e3d2c1b4a9f7e
Content-Type: application/json
{ "status": "live" }
HTTP/1.1 400 Bad Request
X-Request-Id: req_8fK2x9aLp0qR
Content-Type: application/json
{
"error": "invalid_payload",
"message": "Request body failed validation.",
"request_id": "req_8fK2x9aLp0qR",
"details": {
"formErrors": [],
"fieldErrors": {
"name": ["Required"],
"status": ["Invalid enum value. Expected 'draft' | 'active' | 'paused' | 'archived', got 'live'."]
}
}
}
A cross-org access attempt:
GET /v1/chatbots/1b4c4f5d-9e8a-7c3b-2a1d-0f9e8f3a9d2e HTTP/1.1
Authorization: Bearer aw_live_4f8a3c7e2d1b9a5c6f8e3d2c1b4a9f7e
HTTP/1.1 404 Not Found
{
"error": "not_found",
"message": "Chatbot not found.",
"request_id": "req_2c1b4a9f7e8d"
}
The resource exists, but not under your org. We return 404 rather than 403 to avoid leaking the existence of resources in other orgs.