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_cursor is present only when has_more is true. There is no total.
  • 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 status is a 400, 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 with GET /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.