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, the X-Api-Key header, a callback_secret in 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.