Error Responses
A request that does not succeed comes back in one of three different JSON
bodies, and the endpoint decides which — not the status code. One of the three
arrives at HTTP 200, so a client that branches on response.ok alone will read
a rejection as a success.
None of the three includes a success field, and none repeats the HTTP status in
the body.
1. The standard envelope
Most /v2 and /x402/v2 endpoints return this when a request is rejected:
{
"code": "ERR_UNAUTHORIZED",
"message": "Invalid or expired API key",
"errorId": "d2cd7856",
"requestId": "027f86ef-f7da-4486-a914-e3f14aed8877"
}
| Field | Always present | Notes |
|---|---|---|
code | yes | A stable string you can branch on. See Error codes — not every code starts with ERR_. |
message | yes | Human-readable. Wording is not stable; do not match on it. |
details | no | Present on most 4xx. On a 5xx in production it is omitted deliberately, so do not require it. |
errorId | yes | Short id for this failure. Quote it in support requests. |
requestId | yes | Unique per request. Also returned as the x-request-id header. |
When details is present it carries the field-level reason:
{
"code": "ERR_INVALID_PARAMETER",
"message": "Invalid parameters",
"details": [{ "field": "query.keywords", "message": "keywords is required" }],
"errorId": "9d832e9b",
"requestId": "715b9dc6-a0a3-4830-bf1e-bd518c7406c6"
}
2. The short shape
Parts of Auto and the x402 surface reject inside the handler and return only this:
{ "error": "Unsupported exchange" }
There is no code, no errorId and no requestId. A client reading
body.code unconditionally gets undefined and falls through whatever switch
it is driving. An optional details may accompany error, and it is not always
the same type — a string, an object, or an array of { field, message } — so
check its type before indexing it.
Live example: GET /v2/auto/validate-symbol/gmx/BTC returns 400 with exactly
that body, because gmx was removed as a supported venue in 2.7.3.
3. The validation result
Auto returns EQL validation failures as a result object rather than as an error envelope:
{
"valid": false,
"errors": [
{
"code": "EQL_INVALID_JSON",
"message": "Expected object, received string",
"path": ""
}
],
"warnings": []
}
The same body arrives under two different statuses, which is the trap:
| Endpoint | Status | Why |
|---|---|---|
POST /v2/auto/queries/validate | 200 | Checking a query is the endpoint's job, so "invalid" is a successful answer. |
POST /v2/auto/queries | 422 | Creating an invalid query is a failed request. |
So on /v2/auto/queries/validate you must read valid — response.ok is true
either way. Note also that errors[].code is a different vocabulary from the
envelope's code: EQL_INVALID_JSON, EQL_INVALID_ARG_VALUE and friends
describe the query, not the request.
An element of errors or warnings is an object in every case we have observed,
but the schema also permits a bare string — so check the type before reading
.code off it, the same way you would with details.
Handling all three
Branch on the HTTP status first, then look for whichever field the body actually has:
// Wrong — misses every short-shape rejection, misses the four codes that
// carry no ERR_ prefix, and treats an invalid query at 200 as a success.
if (body.code?.startsWith("ERR_")) {
handle(body.code);
}
// Right — the status is always there; the body refines it.
const first = body.errors?.[0];
const firstCode = typeof first === "string" ? first : first?.code;
if (!res.ok) {
handle(res.status, body.code ?? body.error ?? firstCode ?? "unknown");
} else if (body.valid === false) {
handleInvalidQuery(body.errors);
}
Error codes
These are the codes the documented /v2 and /x402/v2 endpoints raise. New
codes can appear without a breaking change, so treat an unrecognised code as
the HTTP status implies rather than as a failure to parse.
| Code | HTTP | Means | Next step |
|---|---|---|---|
ERR_UNAUTHORIZED | 401 | Missing, invalid or expired API key. | Check the x-elfa-api-key header. See Authentication. |
ERR_FORBIDDEN | 403 | The key is valid but not entitled to this endpoint. | Check your plan against Pricing. |
ERR_INSUFFICIENT_CREDITS | 402 | Not enough credits for the call. API-key surface only — see 402 on x402. | Top up, or wait for the monthly reset. |
ERR_BAD_REQUEST | 400 | The request was malformed. | Read message; fix and resend. |
ERR_INVALID_PARAMETER | 400 | A parameter is missing or invalid. | Read details for the field. |
ERR_NOT_FOUND | 404 | No route, or the resource does not exist. | Check the path against the API reference. |
ERR_RATE_LIMITED | 429 | Request rate exceeded. | Back off and retry. See Rate Limits. |
ERR_RATE_LIMIT_EXCEEDED | 429 | Request rate exceeded, raised by a different limiter. | Same handling — branch on 429, not on which of the two codes you got. |
ERR_SERVICE_UNAVAILABLE | 503 | A dependency the endpoint needs declined or could not serve the request. | Retryable — see below. |
EXTERNAL_API_ERROR | upstream's status | An upstream service returned an error, so this one is not always 5xx. | Retry if the status is 5xx; otherwise fix the request. |
EXTERNAL_API_REQUEST_FAILED | 500 | An upstream service did not respond at all. | Retryable. |
INTERNAL_SERVER_ERROR | 500 | Unhandled failure on our side. | Retry once; if it persists, send us errorId and requestId. |
A 422 on Auto does not appear here because it does not carry a code — it
returns the validation result instead.
Two codes cover 429 and two more cover upstream failures. That is why the
status, not the code, is the right thing to branch on — the code tells you why,
the status tells you what to do.
402 means something else on x402
On /x402/v2/* a 402 is not an error. It is the payment handshake: the body is
an empty object {} and the quote is carried in the PAYMENT-REQUIRED
response header, base64-encoded. A client that maps 402 to "insufficient
credits" will misread its first quote as a billing failure and never look at the
header. See x402 Payments.
ERR_INSUFFICIENT_CREDITS is the API-key surface only.
Which failures are worth retrying
- Retry with backoff:
429,503, and500(INTERNAL_SERVER_ERROR,EXTERNAL_API_REQUEST_FAILED, orEXTERNAL_API_ERRORwhere the status is5xx). - Do not retry unchanged:
400,401,402,403,404and422. The same request will fail the same way — change the request, the key, or the balance first.
A 503 in particular is not an empty result. Where an endpoint can return an
empty payload at 200, it does so deliberately, and a 503 means the request
could not be served at all — so retrying it is meaningful, whereas rendering it
as "no data" is not.
Reporting a failure
Every response carries an x-request-id header, including the short shape, so
that is the one identifier you can always quote. Send it along with the errorId
from the body when there is one, plus the endpoint and the time. Together they
identify the exact request in our logs.
For questions about API errors, please contact our support team.