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

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.