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 |
insufficient_permissions |
403 |
The key is scoped for this endpoint, but this key's user may not perform the action |
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 |
Automatic detection could not pick the upload's email column — 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) |
multiple_file_parts |
400 |
The multipart body carried more than one file part — send exactly one |
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 |
result_in_progress |
409 |
The result is still being produced, so it cannot be deleted yet |
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 already in use on the account for a different request |
idempotency_resource_gone |
404 |
A charged Idempotency-Key named a resource that was lost to an erasure or to retention |
result_deleted |
404 |
A charged Idempotency-Key named a result you have since deleted |
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, multiple_file_parts |
401 |
unauthorized, invalid_api_key |
402 |
insufficient_credits |
403 |
insufficient_scope, insufficient_permissions |
404 |
not_found, idempotency_resource_gone, result_deleted |
405 |
invalid_request — the method is not supported on this path |
409 |
job_not_pending, result_in_progress, 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
Every example below is abridged: doc_url and request_id are on every one of these responses
and are left out here so the part that differs stands out. The envelope at the top of this page
shows the full shape.
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.
Email column could not be detected — 422:
{
"status": 422,
"code": "needs_column_selection",
"error": "Automatic detection could not pick the email column. candidate_columns lists every column in the file by position, with its row-1 header text in candidate_column_names — re-send the request with email_column set to the one to validate.",
"candidate_columns": ["Column1", "Column2"],
"candidate_column_names": ["work_email", "personal_email"]
}
candidate_columns are 1-based positional tokens, one for every column in the file —
Column1 is the first column, Column2 the second. It is a complete enumeration, not a shortlist:
automatic detection found no single match, and nothing has been measured about which of these columns
could hold an address. Send one of them back as email_column.
There is one exception to "Column2 is the second column", and it is the only one: email_column
is resolved by name first, so if a cell in row 1 is literally the text Column2, sending Column2
selects that column wherever it sits rather than the second one — and the two are indistinguishable
in this response, because the string appears in both arrays. Check candidate_column_names for a cell
that looks like a positional token; when one is there the only unambiguous fix is to rename that
header. Whichever column resolves is the one both quoted and validated, so you are never billed for a
column we did not check.
candidate_column_names holds the row-1 cell text for each token, in the same order and of the
same length — so candidate_column_names[8] names candidate_columns[8], and you can map
Column9 to e-mail without opening the file.
It is an empty array — all of it, never part of it — unless row 1 is evidently a header: either
you sent has_header=true, or no cell in row 1 reads as an address. Leaving has_header out is not
the same as saying there is a header, so for a headerless file no names are offered: they would be a
row of your own data, and sending one back as email_column would make row 1 be read as a header —
charging and validating one row fewer than the token does. When the names are empty, the tokens are
the answer, and the error message says so rather than pointing you at a field that is empty on
purpose.
It is empty for one more reason: row 1 being entirely blank. An empty string is not a selectable
value — an empty email_column means "no column given" and comes back as this same 422 — so a list
of them is withheld rather than offered. When only some row-1 cells are blank the list still arrives
in full, with "" in those positions; select those columns by their positional token.
Three distinct causes empty the array, and the error message tells you which one you hit — so you
never have to probe the field to find out:
| cause | what the message says | what to do |
|---|---|---|
| A cell in row 1 reads as an email address | row 1 cannot be told apart from the data, and naming it could publish one of your own addresses as a column name | look at row 1; send has_header=true if it really is a header |
You sent has_header=false |
row 1 is data because you said so, and the file carries no header names | nothing to fix — use the positional token |
| Row 1 is entirely blank | row 1 is entirely blank, so it names no columns | use the positional token |
The positional token is the route in all three.
A name that appears twice in row 1 is listed twice, because that is what the file says and it is why
detection could not decide. Sent back as email_column it resolves to its first occurrence, so
duplicated columns are distinguished only by their tokens. email_column does accept 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, and POST /api/v1/results/delete_all, accept an Idempotency-Key
header, which adds four 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 is already in use on the account for a different request — by you or by a teammate |
result_deleted |
404 |
The result this key relates to has since been deleted |
idempotency_resource_gone |
404 |
The key's charge created a resource that was lost to an erasure or to retention |
The last two say the same thing about the resource — it can no longer be read — and differ in who caused it, which is why they are two codes.
result_deleted is the ordinary one. You, or a teammate, deleted the result (DELETE /api/v1/verify/{id},
DELETE /api/v1/verify/batch/{id}, POST /api/v1/results/delete_all, or the dashboard) while the key
was still inside its 24-hour life. Nothing went wrong and nothing is lost that anyone wanted kept.
The key keeps answering result_deleted for the rest of its 24 hours: it cannot replay the original
response — that described the thing you deleted — but it is safe to retry, and a retry will not
re-run the verification or charge you again. To validate the same address or file again, send a
new Idempotency-Key. Deleting a result is never refunded: it was produced and charged for.
idempotency_resource_gone is the one you should never see. It means a request charged you, and the
resource that charge created became unreadable for a reason you did not ask for — an account
erasure, or the 7-day retention delete landing inside the key's 24-hour life. Retrying cannot bring
it back. Quote the request_id and tell us.
{
"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",
"param": "Idempotency-Key",
"error": "This Idempotency-Key is already in use on this account for a different request. Keys are shared by everyone on the account, so a teammate may have used this one. Retry with a key unique to this request."
}
Idempotency-Key values are scoped to the account, never to the member who sent them, so on a
team this can be a key a colleague claimed — which is why the message does not say "you already
used this key". It deliberately does not name the colleague or their file either: the fix is the
same either way, so send a key that is unique to this request. See
Idempotency.
{
"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.