Errors
Every failure on /api/v1 returns the same JSON envelope. Branch your code on the
machine-readable code field — never on the error prose, which is human-facing and may be
reworded at any time without a version bump (see
Versioning).
On this page
The error envelope
{
"status": 400,
"code": "invalid_email",
"error": "Invalid email format.",
"param": "email",
"details": null,
"doc_url": "https://emailvalidator.ai/docs/get-started/errors",
"request_id": "0HNCV1TQ3PLRK:00000003"
}
| Field | Always present | Description |
|---|---|---|
status |
yes | The HTTP status code, mirrored into the body |
code |
yes | Stable machine-readable code — the field to branch on |
error |
yes | Human-readable message. Prose; do not match on it |
doc_url |
yes | Link to this page |
request_id |
yes | Correlation id for the request. Quote it in a support request |
param |
no | The request field the failure is attributable to, when it is about one field |
details |
on this shape | Array of per-field messages. Written as null when there is nothing to add |
status, code, error, doc_url and request_id are on every error the API returns. Only
those five are guaranteed everywhere — see the two typed bodies below.
request_id is also returned as the X-Request-Id response header on every /api/v1
response, success or failure — the header and the body field are always the same value.
Two bodies carry typed extras
The batch 402 and the batch 422 add machine-readable fields on top of the five guaranteed ones,
so a client can act on numbers and names instead of parsing prose. In exchange they carry no
param and no details — there is nothing those could add that the typed fields do not already
say. Both are shown below.
Error codes
The complete vocabulary. A code is a published contract: it will not be renamed inside v1.
code |
Status | Meaning |
|---|---|---|
unauthorized |
401 |
No API key was supplied |
invalid_api_key |
401 |
A key was supplied but is unknown, revoked, expired, or past its scheduled revocation |
insufficient_scope |
403 |
The key authenticated but is not scoped for this endpoint |
invalid_request |
400, 405, 415 |
The request is malformed — wrong content type, missing required field, unsupported method |
invalid_email |
400 |
The submitted address is not a syntactically valid email |
invalid_callback_url |
400 |
The supplied callback_url is not an allowed webhook target |
needs_column_selection |
422 |
The upload has several candidate email columns — re-send with email_column |
too_many_rows |
400 |
The upload holds more valid addresses than this key may submit in one batch |
unsupported_file_type |
400 |
The file extension is not in the allow-list (.csv, .txt, .xlsx) |
payload_too_large |
413 |
The request body or upload exceeds the maximum allowed size |
insufficient_credits |
402 |
The account cannot cover the request |
not_found |
404 |
The resource does not exist, or belongs to another account |
job_not_pending |
409 |
The batch job is no longer pending, so it cannot be started |
rate_limited |
429 |
The per-minute request budget is exhausted |
idempotency_in_progress |
409 |
An identical idempotent request is still in flight |
idempotency_key_reuse |
422 |
An Idempotency-Key was reused with a different request |
idempotency_resource_gone |
404 |
A charged Idempotency-Key named a resource that can no longer be read |
internal_error |
500 |
An unhandled server-side failure |
Status codes
| Status | Codes it carries |
|---|---|
400 |
invalid_request, invalid_email, invalid_callback_url, too_many_rows, unsupported_file_type |
401 |
unauthorized, invalid_api_key |
402 |
insufficient_credits |
403 |
insufficient_scope |
404 |
not_found, idempotency_resource_gone |
405 |
invalid_request — the method is not supported on this path |
409 |
job_not_pending, idempotency_in_progress |
413 |
payload_too_large |
415 |
invalid_request — unsupported Content-Type |
422 |
needs_column_selection, idempotency_key_reuse |
429 |
rate_limited |
500 |
internal_error |
Framework-level rejections carry the envelope too: an unrouted path, a wrong method, an unsupported content type and a malformed path parameter all answer with JSON, never with an empty body.
Examples
Missing API key — 401:
{
"status": 401,
"code": "unauthorized",
"error": "API key required."
}
Supplied key is unknown, revoked or expired — 401:
{
"status": 401,
"code": "invalid_api_key",
"error": "Invalid or revoked API key."
}
Key is not scoped for the endpoint — 403:
{
"status": 403,
"code": "insufficient_scope",
"error": "This API key is not scoped for this endpoint. It requires the 'verify' scope."
}
See Authentication for the scope vocabulary.
Insufficient credits — 402:
The credit numbers are top-level fields, not prose, so you can top up by shortfall and retry
without parsing the message.
{
"status": 402,
"code": "insufficient_credits",
"error": "Insufficient credits.",
"shortfall": 5,
"balance": 5,
"required": 10
}
POST /api/v1/verify returns the plain envelope for 402 (a single check costs one credit, so
there is nothing to itemise); POST /api/v1/verify/batch returns the three numbers above.
Ambiguous email column — 422:
{
"status": 422,
"code": "needs_column_selection",
"error": "Several columns could hold email addresses. Re-send the request with email_column set to the one to validate.",
"candidate_columns": ["Column1", "Column2"]
}
candidate_columns are 1-based positional tokens, one per column in the file — Column1 is the
first column, Column2 the second. Send one of them back as email_column. The field also accepts
the literal header text if you would rather name the column, but the tokens are what the API
returns, because a file may have no header row at all.
Rate limit exceeded — 429:
{
"status": 429,
"code": "rate_limited",
"error": "Rate limit exceeded."
}
A 429 also carries Retry-After and the three X-RateLimit-* headers. See
Rate limits.
Unexpected server error — 500:
{
"status": 500,
"code": "internal_error",
"error": "An unexpected error occurred."
}
A 500 never carries an exception type, message or stack trace. Quote the request_id in a
support request and we can find the exact call.
Idempotency errors
Both credit-spending POSTs accept an Idempotency-Key header, which adds three failure cases:
code |
Status | When |
|---|---|---|
idempotency_in_progress |
409 |
The first request with this key has not finished yet. Retry shortly |
idempotency_key_reuse |
422 |
This key was already used for a different request |
idempotency_resource_gone |
404 |
The key's charge created a resource that can no longer be read |
idempotency_resource_gone is the one of the three you should never see. It means a request charged
you, and the resource that charge created has since become unreadable — an account erasure, or the
7-day retention delete landing inside the key's 24-hour life. Retrying the same key cannot help, so it
carries its own code rather than the generic not_found: an ordinary miss is usually the caller's
mistake and this is ours. Quote the request_id.
{
"status": 409,
"code": "idempotency_in_progress",
"error": "A request with this Idempotency-Key is still in progress. Retry shortly."
}
{
"status": 422,
"code": "idempotency_key_reuse",
"error": "This Idempotency-Key was already used for a different request."
}
{
"status": 404,
"code": "idempotency_resource_gone",
"error": "The resource this Idempotency-Key paid for is no longer available."
}
A key longer than 255 characters is rejected as 400 invalid_request with
param: "Idempotency-Key". See Idempotency.
Finding the request afterwards
Every /api/v1 response carries X-Request-Id, and every error body repeats it as
request_id. Both name the same row in a per-request log you can read yourself:
- Dashboard → API → Logs. Method, path, status, latency, live/test tag and the machine error code for every call, with the redacted request and response bodies on the detail view.
- Retention is 7 days, matching validation-result retention.
- Secrets are redacted before storage. An
?api_key=query value, theX-Api-Keyheader, acallback_secretin a response body and the signing parameters of a pre-signed download URL never reach the log. Request bodies are capped, and a batch upload's file body is never captured at all — only its name and length. - The log is dashboard-only. There is no public endpoint for it, and none is planned.
The log is best-effort
Rows are written off the request path, so logging can never slow down or fail an API call. That is a deliberate trade: under an extreme burst a small number of rows are dropped rather than queued, so a call you made may occasionally be missing from the Logs tab. Nothing about the call itself changes — it is served, charged and answered exactly the same way.
X-Request-Id is returned on every response either way, and it is still worth quoting in a
support request when the call is not in your Logs tab: it identifies the request in our own
server-side logs too.
Log the X-Request-Id of any call you make. It turns "something failed yesterday" into one
row we can both look at.