Verify a batch
POST /api/v1/verify/batch uploads a file of email addresses and starts a batch validation
job. Once complete, results are available as pre-signed CSV and JSON download URLs.
On this page
Upload a file
The request uses multipart/form-data.
| Field | Type | Description |
|---|---|---|
file |
file | .csv, .txt or .xlsx file containing email addresses |
callback_url |
text | (optional) HTTPS URL for a webhook when the job completes |
email_column |
text | (optional) Name of the column holding the addresses. Required only when the file has several candidates — see the 422 below |
has_header |
text | (optional) true/false override when header detection would get it wrong |
Other columns in the file are passed through to the result file unchanged.
Upload limits
| Limit | Live key | Test key (ev_test_) |
|---|---|---|
| Request size | ~28 MiB — over it, 413 payload_too_large |
10 MiB |
| Billable rows in one file | 1,000,000 — over it, 400 too_many_rows |
1,000 |
Both apply to every key, not only to sandbox keys. Nothing is created and no credits are touched when either is exceeded.
The live request ceiling is the whole multipart request, not the file alone: the server's request limit is 30,000,000 bytes (28.6 MiB), so that is what a live upload actually meets. The 10 MiB test cap is a file-size cap and is reached first for a test key. In practice the ceiling you will meet before any of these is the row count — see Very large files below.
Very large files
Files in the tens of thousands of rows are routine. Beyond roughly 60,000 rows an upload is currently unreliable and can fail even though it is inside the limits above. The 1,000,000-row cap is the enforced ceiling, not a supported size; end-to-end streaming for files that large is planned and not shipped. If you have a list bigger than that, split it into several uploads — each job is independent, and the jobs list and webhooks work the same for each — or talk to us before you build around one large upload.
Optional headers:
| Header | Description |
|---|---|
Idempotency-Key |
Makes a retry safe — the second call replays the first response instead of buying a second job. See Idempotency |
This endpoint requires the verify scope. See
Authentication.
POST /api/v1/verify/batch
X-Api-Key: ev_your_key
Content-Type: multipart/form-data
--boundary
Content-Disposition: form-data; name="file"; filename="emails.csv"
Content-Type: text/csv
email
alice@example.com
bob@example.org
--boundary
Content-Disposition: form-data; name="callback_url"
https://yourapp.example.com/hooks/batch
--boundary--
200 OK:
{
"job_id": "7a4e2c1b-...",
"livemode": true,
"total_rows": 2,
"skipped_rows": 0,
"charged": 2,
"credits_remaining": 4817,
"status": "processing",
"callback_secret": "bv_1c3e5a70..."
}
| Field | Description |
|---|---|
total_rows |
Data rows in the file — every row under the header, whatever its email cell holds. total_rows (+ 1 for a header row) = charged + skipped_rows |
skipped_rows |
Rows not charged: the header row, plus every data row whose email cell is not charged |
charged |
Rows charged at upload. A 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), 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 @ (a@, @example.com, mailto:@example.com, <>@example.com, x@;). This is the most you pay. Money only ever comes back, as refunds for addresses we cannot resolve |
callback_secret |
Present only when a callback_url was supplied. A per-job override of your account signing secret; you do not have to keep it |
Email column could not be detected — 422
When automatic detection cannot pick the email column, the job is not created and nothing is
charged. Pick a column and re-send with email_column:
{
"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, and so on. 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. candidate_column_names gives the row-1 cell text for each
token, in the same order and of the same length, so a 67-column workbook does not have to be opened
and counted by hand.
"Column2 is the second column" has one exception: 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 — and this response cannot tell you which was meant, because the same string is in both arrays.
Check candidate_column_names for a cell that looks like a positional token; when one is there, rename
that header. The column that resolves is always the column that is both quoted and validated.
The names are all there or all absent, and they are only there when row 1 is evidently a header —
you sent has_header=true, or no cell in row 1 reads as an address. Omitting has_header is not a
claim that there is a header, so a headerless file gets no names: 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 one row
fewer than the token does. The names are also absent when row 1 is entirely blank: an empty string
is not a selectable value (an empty email_column means "no column given" and is answered with this
same 422), so a list of them is withheld. A partly blank row still arrives in full, "" included —
select those columns by their token.
Three distinct causes empty the array, and the error message tells you which one you hit: a cell
in row 1 reads as an address (so row 1 cannot be told from the data — look at row 1); you sent
has_header=false (so row 1 is data because you said so — nothing to fix); or row 1 is entirely blank
(so it names nothing). The positional token is the route in all three.
A name repeated in row 1 is listed each time and resolves to its first
occurrence, so only the token distinguishes duplicated columns. Send a token back as email_column:
Content-Disposition: form-data; name="email_column"
Column2
email_column also accepts the literal header text (work_email) if you would rather name the
column. Positional tokens are what the API returns because a file may have no header row at all.
Guessing would risk billing you for the wrong column, so the API asks instead.
Poll for job status
GET /api/v1/verify/batch/7a4e2c1b-...
X-Api-Key: ev_your_key
While processing — 200 OK:
{
"job_id": "7a4e2c1b-...",
"livemode": true,
"status": "resolving",
"total_rows": 2,
"billed_rows": 2,
"original_file_name": "emails.csv",
"created_at": "2025-06-10T14:00:00Z",
"started_at": "2025-06-10T14:00:01Z",
"completed_at": null,
"expires_at": "2025-06-17T14:00:00Z",
"csv_url": null,
"json_url": null
}
Completed — 200 OK:
{
"job_id": "7a4e2c1b-...",
"livemode": true,
"status": "complete",
"total_rows": 2,
"billed_rows": 2,
"original_file_name": "emails.csv",
"created_at": "2025-06-10T14:00:00Z",
"started_at": "2025-06-10T14:00:01Z",
"completed_at": "2025-06-10T14:00:45Z",
"expires_at": "2025-06-17T14:00:00Z",
"unprocessed_count": 0,
"unique_addresses": 2,
"dupe_count": 0,
"parse_errors": 0,
"non_address_rows": 0,
"csv_url": "https://storage.example.com/results/...?X-Amz-Expires=...",
"json_url": "https://storage.example.com/results/...?X-Amz-Expires=..."
}
Row accounting
A completed job describes your file, and its numbers add up to each other:
total_rows = unique_addresses + dupe_count + non_address_rows
unique_addresses = deliverable + catch-all + invalid + unknown + parse_errors
| Field | Description |
|---|---|
total_rows |
Data rows in the file |
unique_addresses |
Distinct addresses in the file, malformed ones included. A repaired address counts under its corrected form |
dupe_count |
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 |
parse_errors |
Distinct malformed addresses — charged cells that no parse or repair turns into an address. They have no Outcome and are refunded unless a recovery is confirmed |
non_address_rows |
Rows whose email cell is blank or holds no address. Never charged |
All four are null until the job completes. ⚠ unique_addresses used to exclude malformed addresses and
dupe_count used to be total_rows − unique_addresses, which reported malformed and blank rows as
duplicates; total_rows used to be the charged count (now billed_rows). On a file with no malformed,
blank or no-@ cells, nothing changes.
Job statuses
GET /api/v1/verify/batch/{id} and the list endpoint report the job's real lifecycle stage:
status |
Meaning |
|---|---|
pendingapproval |
Created but not started |
resolving |
Resolving MX records |
smtping |
Probing mail servers |
assembling |
Building the result files |
complete |
Finished — download URLs available |
failed |
The job could not be completed |
POST /api/v1/verify/batch answers status: "processing" at creation, which is the submission
acknowledgement rather than a lifecycle stage. Treat anything that is not complete or failed as
"still working", so a new stage added later does not break your client.
Listing your jobs
Lost a job_id? GET /api/v1/verify/batch lists the jobs submitted with this key's account,
newest first — the only way to recover a job whose id you no longer hold.
GET /api/v1/verify/batch?limit=25&status=complete
X-Api-Key: ev_your_key
200 OK:
{
"object": "list",
"data": [
{
"job_id": "7a4e2c1b-...",
"livemode": true,
"status": "complete",
"total_rows": 1000,
"billed_rows": 995,
"original_file_name": "emails.csv",
"created_at": "2025-06-10T14:00:00Z",
"started_at": "2025-06-10T14:00:01Z",
"completed_at": "2025-06-10T14:00:45Z",
"expires_at": "2025-06-17T14:00:00Z"
}
],
"has_more": true,
"next_cursor": "MTc0OTU2NzIwMDAwMDAwMDAwfDdhNGUyLi..."
}
| Parameter | Description |
|---|---|
limit |
Rows per page. Clamped to 1–100; default 25 |
starting_after |
A next_cursor from a previous response. Walks towards older jobs |
ending_before |
A next_cursor from a previous response. Walks back towards newer jobs. Ignored when starting_after is also supplied |
status |
Return only jobs in this state, from the table above. Matched case-insensitively |
The list is scoped to your key's mode as well as your account: a sandbox (ev_test_) key
sees only sandbox jobs and a live key only live ones. Every job carries livemode (true = live)
so a stored response says which it was. See
Authentication.
next_cursoris present only whenhas_moreistrue. There is nototal.- A malformed cursor is a
400, naming the parameter it arrived in — see Cursors that no longer decode for why it is not silently treated as page one. Omitting the parameter is still page one. - An unrecognised
statusis a400, not a silently unfiltered list. - List rows carry no
csv_url/json_url. Each pre-signed URL costs a storage round-trip, so they are produced only for the job you actually want: fetch it withGET /api/v1/verify/batch/{id}. - Only work submitted through the API is returned. Batches started from the web dashboard belong to the dashboard, not to this key.
This endpoint requires the read scope.
Download results
csv_url and json_url are pre-signed URLs valid for 7 days from job completion. After
expiry they return 403; call GET /api/v1/verify/batch/{id} again to obtain fresh URLs
for as long as the job data is retained.
The CSV keeps every column you uploaded and adds the validation columns after them. Its last
column is RepairedFrom. When we repaired an address mechanically before validating it (a doubled
dot in the domain such as user@oxford..edu, a mailto: prefix, a stray angle bracket, comma or
space), Address holds the corrected address and RepairedFrom holds the cell exactly as you sent
it. On every other row RepairedFrom is blank.
If your file already has a column with the same name as one of ours, yours is kept untouched and
ours is added with an _ev suffix — so a file of your own carrying Outcome comes back with your
Outcome and our Outcome_ev. This is what makes re-validating a results file we produced earlier
safe: every column collides, every verdict still arrives, under Address_ev, Outcome_ev and so
on. _ev is reserved for this; a file carrying both Outcome and Outcome_ev of its own is
rejected rather than silently losing a verdict column.
Retention
expires_at is when the job's data is removed — 7 days after submission. After that the job is
indistinguishable from one that never existed: it disappears from the list and
GET /api/v1/verify/batch/{id} answers 404 not_found rather than a separate "expired" error.
Read expires_at while the job is still listed if you need to know when it goes.
Deleting a job
DELETE /api/v1/verify/batch/{id} erases one job permanently — the job record, the file you
uploaded, the generated result files, and every stored copy of the submitted addresses. The
pre-signed download URLs stop working and cannot be reissued.
DELETE /api/v1/verify/batch/7a4e2c1b-...
X-Api-Key: ev_your_key
{
"object": "batch_job",
"id": "7a4e2c1b-...",
"deleted": true
}
It needs the delete scope, which a key holds only if it was asked for by name — see
Authentication.
A job that is resolving, probing or assembling answers 409 with code: "result_in_progress": that
work was charged for and is being delivered, so it finishes first and nothing is refunded. A job
still awaiting approval is cancelled instead of erased — it was never charged either way.
If the job carried a callback_url, deleting it delivers one final batch.deleted webhook: the
job_id and deleted: true, nothing else. A job deleted while it was awaiting approval emits
nothing — it was never started and never reported.
Deleting every result
POST /api/v1/results/delete_all erases every result in the account and mode: single checks
and batch jobs, API-submitted and dashboard-submitted, including results your teammates submitted.
Immediate, irreversible, never refunded.
The sweep is scoped to the calling key's mode, like every other endpoint. A live key erases live
results and leaves sandbox results untouched; a sandbox (ev_test_) key erases sandbox results and
cannot touch a live one.
POST /api/v1/results/delete_all
X-Api-Key: ev_your_key
Content-Type: application/json
Idempotency-Key: 00000000-0000-4000-8000-000000000002
{ "confirm": "delete-all-results" }
{
"deleted": 128
}
The confirm phrase is required verbatim; any other body — including {} — is 400 with
code: "invalid_request" and param: "confirm", and consumes no Idempotency-Key. Results still
being produced are skipped rather than refused: they are not counted in deleted, and the call
still succeeds.
Send an Idempotency-Key and a retry replays the stored count instead of sweeping a second time
over everything created in between.
One caller gets 403 with code: "insufficient_permissions" rather than insufficient_scope —
the key is fine, so a differently scoped key cannot help: the key's user is not the account's owner
or an admin. The sweep takes teammates' results, so deleting all of them is not a plain member's
call, even though deleting one of your own is.
A sandbox key is not refused — it sweeps the account's sandbox results.
Status codes for upload
| Code | Meaning |
|---|---|
200 |
Job accepted and processing started |
400 |
Not multipart or missing file (invalid_request), more than one file part (multiple_file_parts), a file extension outside the allow-list (unsupported_file_type), more billable rows than this key may submit (too_many_rows), or a rejected callback_url (invalid_callback_url) |
401 |
Missing or invalid API key |
402 |
Insufficient credits — the body includes shortfall, balance and required |
403 |
The key is not scoped for this endpoint (insufficient_scope) |
409 |
The job is no longer pending (job_not_pending), or an identical Idempotency-Key request is still in flight (idempotency_in_progress) |
413 |
Upload exceeds the maximum allowed file size (payload_too_large) |
422 |
Automatic detection could not pick the email column (needs_column_selection), or an Idempotency-Key already in use on the account for a different request (idempotency_key_reuse) — keys are shared by every member of the account |
429 |
Rate limit exceeded |
500 |
Unexpected server error (internal_error) |
Status codes for polling and listing
| Code | Meaning |
|---|---|
200 |
Job found; includes download URLs when status is complete |
400 |
On the list endpoint — an unrecognised status filter (invalid_request, param: status) |
401 |
Missing or invalid API key |
403 |
The key is not scoped for this endpoint (insufficient_scope) |
404 |
Job not found, expired, belongs to a different account, or belongs to the other mode (a sandbox key cannot fetch — or download — a live job, or vice versa) |
429 |
Rate limit exceeded |
500 |
Unexpected server error (internal_error) |
Status codes for deleting
| Code | Meaning |
|---|---|
200 |
Deleted — the stub on DELETE, the count on delete_all |
400 |
delete_all without the exact confirm phrase (invalid_request, param: confirm) |
401 |
Missing or invalid API key |
403 |
The key does not hold the delete scope (insufficient_scope), or it does but this actor is not the account's owner or an admin and so may not run delete_all (insufficient_permissions) |
404 |
Job not found, already deleted, or belongs to another account or mode |
409 |
The job is still being produced (result_in_progress), or an identical Idempotency-Key request is still in flight (idempotency_in_progress) |
422 |
An Idempotency-Key already in use on the account for a different request (idempotency_key_reuse) |
429 |
Rate limit exceeded |
500 |
Unexpected server error (internal_error) |
See Errors for the shared envelope.