Upload a CSV file and start a batch validation job
Download OpenAPI specUpload a CSV file of email addresses and start a batch validation job. The CSV
must have an email column; other columns are passed through to the result file.
Credits are deducted immediately on job start based on the number of billable email
rows: every email-column cell with an @ and something on both sides, or a known
repairable pattern. Blank cells, the header row and cells with no @ are never
charged. The charge is the maximum — money only ever comes back, as refunds for
addresses that cannot be resolved. When insufficient credits: 402 with
shortfall, balance, and required in the body.
Test mode — a ev_test_ key runs the batch for free (charged: 0) with no
DNS or SMTP. Each row's outcome is derived from the local part of its address
(the same convention as single verify — valid@, invalid@, catch_all@,
disposable@, role@, unknown@), and the values are canned (produced
without running the live validation pipeline), so a test batch matches single
verify exactly:
| Local part | Outcome |
Risk |
Flags |
|---|---|---|---|
valid (or anything not listed) |
deliverable |
none |
— |
role |
deliverable |
medium |
role |
catch_all |
catch-all |
medium |
catch-all |
invalid |
invalid |
high |
— |
disposable |
invalid |
high |
disposable |
unknown |
unknown |
low |
— |
Test batches are capped at 1,000 billable rows; over the cap the request returns
400 with code: too_many_rows (the machine-readable field — error carries prose;
branch on code). The result is a real downloadable CSV/JSON
(pre-signed URLs) and, when callback_url is supplied, a real signed
batch.completed webhook — the same shapes a live batch produces. Test batches do
not affect your account's lifetime dashboard stats.
Multipart field order does not matter. email_column, has_header and
callback_url are read from the whole request, so they may be sent before or after the
file part and mean the same thing either way. That includes the Idempotency-Key
identity described below: two different orderings of the same upload produce the same
identity, so the second one replays rather than being refused as key reuse. Send the parts
in whatever order your HTTP client produces.
Send each field once. If the same non-file field appears twice in one body, the LAST
occurrence on the wire wins and the earlier one is discarded silently — there is no
refusal and no warning. Only the file part is refused when duplicated (below).
Send exactly one file part. A body carrying two is an ambiguous request and is
refused with 400 multiple_file_parts — neither part is picked for you, and nothing is
stored or charged. Submit multiple files as multiple requests.
Idempotency — send an Idempotency-Key and a retry replays the original response
instead of buying a second job. The request identity is the file NAME, a SHA-256 of the
file's CONTENTS, the resolved email column, the resolved header decision (has_header,
after detection) and the callback_url — every input that can change what you are
charged. Because the digest is taken over the decoded file bytes alone, re-sending the same
file replays even if the multipart boundary, the chunk sizes or the transfer encoding
differ — while a genuinely different request under the same key (a different file, a
different email_column, a flipped has_header, a different callback_url) is refused as
reuse (422 idempotency_key_reuse), never answered with the other request's results.
On a team, the key space is shared by the whole account. The identity is the account,
the key's mode, the endpoint and the digest above — there is no "who sent it" in it. Both
halves of that follow, and both are intended: two members presenting the same key for the
SAME upload is one operation — the second replays the first's response, is charged nothing,
and can read the job it is handed, because batch jobs are account-scoped too. Two members
presenting the same key for DIFFERENT uploads is a collision, and the second is refused
422 idempotency_key_reuse with an error saying the key is already in use on the account
for a different request. Generate a key per upload (a UUID v4) and neither case is reachable.
Requires the verify scope.
Parameters
header · parameters
| Parameter | Type | Description |
|---|---|---|
| Idempotency-Key | string |
Client-generated key (up to 255 characters, one per upload) that makes a retry safe. A repeat with the same upload replays the stored response verbatim and adds Idempotency-Replayed: true; a repeat with a different upload is a 422; a repeat while the first is still in flight is a 409. Records expire after 24 hours and are scoped to the account, the endpoint and the key's mode — to the ACCOUNT rather than to the member, so every member of a team draws on one key space: a teammate's retry of the same upload replays it, and a teammate's different upload under the same key is a 422. If the first request charged for the batch and then died before answering, the retry gets 202 carrying the BatchJobResponse for the job that charge created. A replayed retry still transfers its bytes — the file name is only known while streaming the body — but is never charged twice.
example: 00000000-0000-4000-8000-000000000001
|
Request body · multipart/form-data · required
| Field | Type | Description |
|---|---|---|
| file required | string · binary |
The address list to validate — a .csv, .txt or .xlsx file. Any other extension is rejected with code: unsupported_file_type. Columns other than the email column are passed through to the result file unchanged. Exactly ONE file part per request: a body with two is refused with code: multiple_file_parts rather than one of them being chosen for you, whichever of the two carries the unsupported extension. Three edge cases worth knowing. A file part that does not name itself — NO filename= in its Content-Disposition, or filename="" — is treated as upload.csv and parsed as CSV, so send a filename if the content is not CSV; the two spellings of "no name" are one case and get one answer. A part whose Content-Disposition header cannot be parsed at all is skipped, which makes the request look like it carried no file and answers 400 invalid_request. And an EMPTY file part is the same as no file part: also 400 invalid_request, never a zero-row job.
|
| callback_url | string · uri | Optional HTTPS URL to receive a webhook when the batch job completes. |
| email_column | string |
Optional name of the column holding the addresses. Needed only when automatic detection cannot pick one, which is answered with 422 needs_column_selection, every column as a positional token and each one's header text — re-send with this field set to one of them. The API never guesses, because guessing would risk billing for the wrong column. May be sent before or after the file part: the whole body is read before the file is inspected, so part order never changes the outcome.
example: work_email
|
| has_header | string |
Optional override for header detection, for the rare file whose first row is misread as (or mistaken for) a header. Accepted values are exactly Because the header decision changes the billable row count, the RESOLVED value
(after detection) is part of the |
Responses
Idempotency recovery. A previous request with this Idempotency-Key charged for the
batch and then died before it could answer (a crash, a deploy, a dropped connection).
The job exists; this response is that job, carries Idempotency-Replayed: true, and
costs nothing. The batch is never started twice.
The body is the same BatchJobResponse that GET /api/v1/verify/batch/{id} returns —
not a summary of it — so a recovery reports the job's CURRENT status and, if it has
already finished, its csv_url and json_url straight away. There is no earlier
response to replay here, so this is recomputed on every retry rather than frozen: it can
never tell you a job is running hours after it completed.
code: invalid_request), missing file field (code: invalid_request), more than one file part (code: multiple_file_parts), a file extension outside the allow-list (code: unsupported_file_type), or a rejected callback_url (code: invalid_callback_url). A batch over the billable-row cap returns code: too_many_rows — 1,000,000 rows for a live key, 1,000 for an ev_test_ key. Over the size ceiling the request is a 413 payload_too_large instead: for a live key that is the whole multipart request at 30,000,000 bytes (~28 MiB), and for an ev_test_ key it is the file itself at 10 MiB.
· application/json
code: insufficient_scope). It is a 403 rather than a 401 on purpose — the credential is valid, so retrying with it can never help; a key holding the verify scope is needed.
· application/json
shortfall, balance, and required.
· application/json
The resource a charged Idempotency-Key paid for can no longer be read. Two codes, and
the difference is who caused it:
result_deleted — you (or a teammate) DELETED the result, via DELETE /api/v1/verify/{id},
DELETE /api/v1/verify/batch/{id}, POST /api/v1/results/delete_all, or the dashboard.
Expected, and nothing to report: the result is gone because it was asked to be.
idempotency_resource_gone — it became unreadable for a SYSTEMIC reason: an account
erasure, or the 7-day retention delete landing inside the key's 24-hour life. That is a
server-side fault worth reporting; quote the request_id.
Both carry their OWN code rather than the generic not_found, because they mean something
different from it: not_found is an ordinary miss you may have caused, and unlike it,
retrying the same key here cannot ever succeed.
Retrying is nevertheless safe. The deleted result leaves its claim behind as a
tombstone for the remainder of its 24 hours, so every retry inside that window gets this
same answer and moves no money — it never re-runs the work and never charges again. To
submit the same address or file for real, use a NEW Idempotency-Key.
email_column set to one of the returned positional tokens (or to the header cell text). That field may sit before or after the file part — multipart field order does not affect the request.
· application/json
Response schema · 200 · BatchStartResponse
POST /api/v1/verify/batch when a job is accepted.| Field | Type | Description |
|---|---|---|
| job_id | string · uuid | Unique identifier for the batch job. example: 7a4e2c1b-0000-0000-0000-000000000000 |
| livemode | boolean |
true when the job was submitted with a LIVE key, false for an ev_test_ sandbox key whose rows get canned verdicts and cost no credits. Read from the created job's stored mode, so it cannot disagree with what a later GET of the same job reports. It rides on this body — the first one you see for a new job, and the one you are likely to log — rather than only on the reads.
example: true
|
| total_rows | integer |
Data rows in the uploaded file — every row under the header, whatever its email cell holds. Not the charge (that is charged): total_rows, plus 1 when the file has a header row, equals charged + skipped_rows. The same total_rows the job, the batch.completed webhook and summary.json report.
example: 1000
|
| skipped_rows | integer |
Rows not charged by the upload scan — the header row, plus every data row whose email cell is not charged (see charged).
example: 5
|
| charged | integer |
Rows charged at upload. An email-column cell is charged when it is an address, when a mechanical repair makes it one (a doubled dot in the domain, a mailto: prefix, an unbalanced angle bracket, or whitespace, commas or semicolons at the edges; a single trailing root dot is already a legal address), or when — after those repairs — it still has at least one real character before its first @ and after its last @. Whitespace, commas, semicolons, angle brackets, quotes and @ are not real characters. Never charged: the header row, blank cells, cells with no @, and cells with nothing real on one side of the @, such as a@, @example.com, mailto:@example.com, <>@example.com and x@;. This is the most you pay. Money only ever comes back, as refunds for addresses that cannot be resolved.
example: 995
|
| credits_remaining | integer · null |
Credit balance after deduction. null for system/demo accounts.
example: 4817
|
| status | string |
Always processing immediately after job creation.
processing
example: processing
|
| callback_secret | string |
Per-job HMAC secret. Included only when callback_url was provided in the request; it is not retrievable later. Supplying/keeping it is optional — it OVERRIDES the account signing secret for this job's delivery only. The documented default is your account signing secret (whsec_…), which you can pin once instead of tracking one secret per job. See components.headers.X-Signature.
example: bv_1c3e5a70...
|
Response schema · 202 · BatchJobResponse
GET /api/v1/verify/batch/{id}.| Field | Type | Description |
|---|---|---|
| job_id | string · uuid | example: 7a4e2c1b-0000-0000-0000-000000000000 |
| livemode | boolean |
true when the job was submitted with a LIVE key; false when it came from an ev_test_ sandbox key. A key only ever lists — and can only ever fetch — jobs of its own mode.
example: true
|
| status | string |
The job's lifecycle stage. POST /api/v1/verify/batch answers processing at creation — a submission acknowledgement rather than a stage — so treat anything that is not complete or failed as still working, and a stage added later will not break you.
pendingapproval
resolving
smtping
assembling
complete
failed
example: complete
|
| total_rows | integer |
Data rows in the uploaded file — every row under the header, whatever its email cell holds. On a completed job, total_rows = unique_addresses + dupe_count + non_address_rows. Before September 2026's row-accounting fix this was the charged count; that number is billed_rows.
example: 1000
|
| billed_rows | integer | Number of rows that were billed. example: 995 |
| original_file_name | string | File name as uploaded. example: emails.csv |
| created_at | string · date-time | example: 2025-06-10T14:00:00Z |
| started_at | string · date-time · null | example: 2025-06-10T14:00:01Z |
| completed_at | string · date-time · null |
UTC timestamp when validation completed. null while processing.
example: 2025-06-10T14:00:45Z
|
| expires_at | string · date-time | UTC timestamp when this job's data is removed, 7 days after submission. Always present, on every job state. After it the job answers 404 rather than a distinguishable expiry error, so read it while the job is still listed. example: 2025-06-17T14:00:45Z |
| unprocessed_count | integer · null |
How many addresses in the file could not be processed — they produced no result, were never probed, and are refunded. Each is marked Unprocessed=True in the CSV. 0 on a healthy job; null until the job completes.
example: 0
|
| unique_addresses | integer · null |
Distinct addresses in the file — every distinct address, including malformed ones (see parse_errors). A repaired address counts under its corrected form. null until the job completes. ⚠ Changed meaning: it used to exclude malformed addresses.
example: 987
|
| dupe_count | integer · null |
Rows that repeat an address already in the file — counted row by row. A malformed address that appears once is never a duplicate, and neither is a blank or no-@ row. null until the job completes. ⚠ Changed meaning: it used to be total_rows − unique_addresses, which reported malformed and blank rows as duplicates.
example: 11
|
| parse_errors | integer · null |
Distinct malformed addresses — cells that are charged (see charged on the upload response) but that no parse or repair turns into an address. Part of unique_addresses; they have no Outcome and are refunded unless a recovery is confirmed. null until the job completes.
example: 3
|
| non_address_rows | integer · null |
Data rows whose email cell is blank or holds no address. Never charged. null until the job completes.
example: 2
|
| csv_url | string · null |
Pre-signed download URL for the CSV result file. Valid for 7 days. null while processing. The CSV's last column is RepairedFrom: when an address was repaired mechanically before validation (a doubled dot in the domain, a mailto: prefix, a stray angle bracket, comma or space), Address holds the corrected address and RepairedFrom the cell exactly as sent. It is blank on every other row. A validation column whose name the uploaded file already uses is added under a reserved _ev suffix (Outcome_ev) rather than replacing or displacing yours — so re-validating a results file we produced earlier still carries every verdict. A file carrying both Outcome and Outcome_ev of its own is rejected rather than losing a verdict column.
example: https://storage.example.com/results/job.csv?X-Amz-Expires=604800
|
| json_url | string · null |
Pre-signed download URL for the JSON result file. Valid for 7 days. null while processing.
example: https://storage.example.com/results/job.json?X-Amz-Expires=604800
|
Error responses
400 401 403 404 409 413 429 500 · ApiProblem · object
| Field | Type | Description |
|---|---|---|
| status required | integer | HTTP status code mirrored in the body. example: 400 |
| code required | string |
The stable, machine-readable error vocabulary. Every Branch on this and never on the Three of these are specific to deletion. unauthorized
invalid_api_key
insufficient_scope
insufficient_permissions
invalid_request
invalid_email
invalid_callback_url
insufficient_credits
not_found
job_not_pending
result_in_progress
needs_column_selection
too_many_rows
unsupported_file_type
multiple_file_parts
payload_too_large
rate_limited
idempotency_in_progress
idempotency_key_reuse
idempotency_resource_gone
result_deleted
internal_error
example: invalid_email
|
| error required | string | Human-readable error message. example: Invalid email format. |
| param | string | The request field the failure is attributable to. Omitted when the failure is not about one specific field. example: email |
| details | array of string · null | Optional per-field validation messages. Null when not applicable. example: ["email: required"] |
| doc_url | string | Link to the documentation for this error code. example: https://emailvalidator.ai/docs/get-started/errors |
| request_id | string | Correlation id for this request. Quote it in a support request — it is how a single call is found in the server logs. example: 0HNCV1TQ3PLRK:00000003 |
402 · InsufficientCreditsProblem · object
402 body of a batch submission: the standard error envelope plus the typed credit numbers, kept top-level so a client can top up by shortfall and retry without parsing prose.| Field | Type | Description |
|---|---|---|
| status required | integer | HTTP status code mirrored in the body. example: 402 |
| code required | string |
Stable, machine-readable error code.
insufficient_credits
example: insufficient_credits
|
| error required | string | Human-readable error message. example: Insufficient credits. |
| shortfall required | integer | Credits missing for this batch — buy at least this many, then retry. example: 5 |
| balance required | integer | The account's credit balance when the batch was submitted. example: 5 |
| required required | integer | Credits the batch would have cost. example: 10 |
| doc_url | string | Link to the documentation for this error code. example: https://emailvalidator.ai/docs/get-started/errors |
| request_id | string | Correlation id for this request. example: 0HNCV1TQ3PLRK:00000003 |
422 · NeedsColumnSelectionProblem · object
422 body of a batch submission whose email column automatic detection could not pick: the standard error envelope plus every column in the file, by position, with its header text.| Field | Type | Description |
|---|---|---|
| status required | integer | HTTP status code mirrored in the body. example: 422 |
| code required | string |
Stable, machine-readable error code.
needs_column_selection
example: needs_column_selection
|
| error required | string |
Human-readable error message. Chosen first by whether candidate_column_names is populated — the example below is the sentence sent when it is — and, when it is empty, by WHICH of the three causes emptied it: a cell in row 1 reads as an address (so row 1 cannot be told from the data), has_header=false was sent (so you told us row 1 is data), or row 1 is entirely blank (so it names nothing). Every empty-names sentence names the positional token as the route. It never promises names this body does not carry, and never states a cause that is not this file's.
example: 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 required | array of string |
Every column in the file, as 1-based positional tokens — One exception to that, and it is the only one: This is a complete enumeration, not a shortlist: nothing has been measured about which of these columns could hold an address, only that automatic detection found no single match. A 67-column file is answered with all 67 tokens. They are positional rather than header names because a file may have no header row at
all, and the pipeline resolves the same tokens when it later reads the file. The
|
| candidate_column_names required | array of string |
The row-1 cell text for each entry of Additive: the value you send back as An empty array — all of them, never some — unless row 1 is evidently a header. Row 1
is only named when It is also empty when row 1 is entirely blank — there would be nothing to show, and an
empty string is not a selectable value, because an empty Three distinct things empty this array, and the |
| doc_url | string | Link to the documentation for this error code. example: https://emailvalidator.ai/docs/get-started/errors |
| request_id | string | Correlation id for this request. example: 0HNCV1TQ3PLRK:00000003 |