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.
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 |
Rows found in the file |
skipped_rows |
Non-email or blank rows skipped by the upload scan |
charged |
Addresses actually billed — valid-email rows only |
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 |
Ambiguous email column — 422
If several columns could hold addresses, the job is not created and nothing is charged. Pick one
and re-send with email_column:
{
"status": 422,
"code": "needs_column_selection",
"error": "Several columns could hold email addresses. Re-send the request with email_column set to the one to validate.",
"candidate_columns": ["Column1", "Column2"]
}
candidate_columns are 1-based positional tokens, one per column in the file — Column1 is the
first column, Column2 the second, and so on. Send one of them 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",
"csv_url": "https://storage.example.com/results/...?X-Amz-Expires=...",
"json_url": "https://storage.example.com/results/...?X-Amz-Expires=..."
}
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.
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.
Status codes for upload
| Code | Meaning |
|---|---|
200 |
Job accepted and processing started |
400 |
Not multipart or missing file (invalid_request), a file extension outside the allow-list (unsupported_file_type), more valid 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 |
Several candidate email columns (needs_column_selection), or an Idempotency-Key reused for a different request (idempotency_key_reuse) |
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) |
See Errors for the shared envelope.