Upload a CSV file and start a batch validation job

Download OpenAPI spec
POST /api/v1/verify/batch
API key required Base https://api.emailvalidator.ai 60 requests per minute per account

Upload 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.

On this page

Parameters

header · parameters

ParameterTypeDescription
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

FieldTypeDescription
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 true and false (any casing; surrounding whitespace is trimmed). Any other value — 1, 0, yes, no, on — is ignored and the header row is auto-detected as if the field had not been sent. That is not an error: the request succeeds, and the quote may differ from the one you intended. Send the literal words.

Because the header decision changes the billable row count, the RESOLVED value (after detection) is part of the Idempotency-Key request identity.

example: true

Responses

200
Job accepted and processing started. · application/json
202

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.

· application/json
400
Not multipart (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
401
Missing or invalid API key. · application/json
403
The API key authenticated but is not scoped for this endpoint (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
402
Insufficient credits. Body includes shortfall, balance, and required. · application/json
404

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.

· application/json
409
Job is not in pending state (concurrent submission conflict). · application/json
413
Upload exceeds the maximum allowed file size. · application/json
422
Automatic detection could not pick the email column. Re-POST with 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
429
Rate limit exceeded. · application/json
500
Unexpected server error. The body carries code internal_error plus a request_id to quote in a support request, and never any exception detail. · application/json

Response schema · 200 · BatchStartResponse

Response from POST /api/v1/verify/batch when a job is accepted.
FieldTypeDescription
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

State of a batch job returned by GET /api/v1/verify/batch/{id}.
FieldTypeDescription
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

Standard error response shape used by all error status codes.
FieldTypeDescription
status required integer HTTP status code mirrored in the body. example: 400
code required string

The stable, machine-readable error vocabulary. Every ApiProblem.code is one of these values, and this is the WHOLE vocabulary — any one operation emits only the subset its own responses describe.

Branch on this and never on the error prose, which is human-readable and may be reworded at any time. What each code means, the status it arrives with, and whether retrying can help are documented on the Errors page — the same page every error body links to as its doc_url.

Three of these are specific to deletion. result_in_progress refuses to delete a result that is still being produced. insufficient_permissions refuses the ACTOR rather than the key. result_deleted is what a charged Idempotency-Key answers once the result it named has been deleted.

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

The 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.
FieldTypeDescription
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

The 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.
FieldTypeDescription
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 — Column1 is the first column, Column2 the second. Re-send the request with email_column set to the one to validate.

One exception to that, and it is the only one: email_column is resolved by name first. If a cell in row 1 is literally the text Column2, sending Column2 selects that column, wherever it sits, not the second one — and the two are then indistinguishable in this response, because candidate_columns and candidate_column_names both contain the string. Check candidate_column_names for a cell that looks like a positional token; when one is there, the only unambiguous route is to rename that header. Whichever column resolves, it is the one both quoted and validated.

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 email_column field additionally accepts the literal header cell text, if naming the column is more convenient.

example: ["Column1","Column2"]
candidate_column_names required array of string

The row-1 cell text for each entry of candidate_columns, in the same order and of the same length — so candidate_column_names[8] names candidate_columns[8], and a caller told Column9 can see it is e-mail without opening the file and counting columns.

Additive: the value you send back as email_column is still the positional token (a name is accepted too, when it matches the header cell exactly). When the same name appears in row 1 more than once it is listed each time — that repetition is itself why detection could not decide — and a name sent back resolves to its first occurrence, so only the token tells duplicated columns apart.

An empty array — all of them, never some — unless row 1 is evidently a header. Row 1 is only named when has_header=true was sent, or when no cell in row 1 reads as an address. The absence of has_header=false is not evidence of a header: for a headerless file the names would be one row of your own data, and sending such a value back as email_column makes row 1 be read as a header, charging and validating one row fewer than the token does. So when row 1 cannot be told from data, the names are withheld and the positional tokens are the answer.

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 email_column counts as no column given and is answered with this same 422. When only some row-1 cells are blank the list is still returned in full, with an empty string for those columns; select them by their positional token.

Three distinct things empty this array, and the error message says WHICH, so you never have to probe this field to find out: a cell in row 1 reads as an address (look at your row 1); you sent has_header=false (nothing to fix — that is the file you described); or row 1 is entirely blank. The positional tokens are the answer in all three.

example: ["work_email","personal_email"]
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