Bulk List Verification
Verify a list of up to 100,000 email addresses in one asynchronous job. You submit the list, follow the job by polling or with a webhook, and download the results as JSON or CSV: one result per row of your list, in your order.
Use it to clean mailing lists, CRM exports or sign-up backlogs. To check a single address while a user waits, use the validation endpoint.
Billing
Every unique, syntactically valid address in your list costs one request of your quota, charged when you submit the list. Duplicates and rows without a valid address are free, and addresses we could not verify because of a failure on our side are refunded when the job completes. Checking a job's status, listing, downloading and deleting jobs is free. See Billing for the details.
How it works
- Submit the list with
POST /v1/bulk: a JSON array of addresses, or a CSV or TXT file. The answer (201) is the job: itsjob_id, the counts of your list and the requests charged. - Wait until the job has finished. Either poll
GET /v1/bulk/{job_id}about once a minute untilstatusiscompleted(orfailed), or pass awebhook_urlwhen you submit the list and we send it a signedPOSTonce the job has finished. On the free plan, wait until the minute after you submitted the list before you check it (see Billing). - Download the results with
GET /v1/bulk/{job_id}/results, as JSON or, withformat=csv, as CSV. - Delete the job with
DELETE /v1/bulk/{job_id}once you have the results, or let it expire: it is deleted 30 days after it finished.
The checks run in the background, so you never keep a request open while the list is checked. We spread the checks out per receiving mail server to protect deliverability, so a large list can take a day or longer, especially when many of its addresses belong to the same mail provider or the same company domain.
The same flow with cURL, a Python script that uploads a CSV file and waits for the results, and a JavaScript version:
Complete example
# 1. Submit the list; note the job_id of the answer
curl https://api.emailvalidation.io/v1/bulk \
-H "apikey: YOUR-API-KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["support@emailvalidation.io", "info@example.com"]}'
# 2. Check the job until its status is "completed"
curl https://api.emailvalidation.io/v1/bulk/629954218341568512 \
-H "apikey: YOUR-API-KEY"
# 3. Download the results as CSV
curl -G https://api.emailvalidation.io/v1/bulk/629954218341568512/results \
-d format=csv \
-H "apikey: YOUR-API-KEY" \
-o results.csv
# 4. Delete the job, its list and its results
curl -X DELETE https://api.emailvalidation.io/v1/bulk/629954218341568512 \
-H "apikey: YOUR-API-KEY"
Submit a list
Submits a list for verification and returns the queued job with status code 201. Send the addresses as JSON (with the header Content-Type: application/json), or upload a file as multipart/form-data. The list is read and counted while you wait; the checks run afterwards.
Required attributes
Send exactly one of emails and file.
- Name
emails- Type
- array
- Description
The addresses to verify, in your order: up to 100,000 strings (1,000 with a sandbox key) of at most 1,000 characters each, together at most 25 MB. Items that are not valid addresses, including empty strings, are kept in the results as
invalid_formatand are free.
- Name
file- Type
- file
- Description
A CSV or TXT file of up to 25 MB and 100,000 rows. A file whose name ends in
.txtis read as one address per line, any other file as CSV. See CSV and TXT files.
Optional attributes
- Name
email_column- Type
- string
- Description
CSV files only: the header name (not case-sensitive) or the column number (
1to500) of the column with the addresses. Detected when you leave it out.
- Name
webhook_url- Type
- string
- Description
An
httpsURL on a public host, without user name or password, that receives one signedPOSTwhen the job has completed or failed. See Webhooks.
- Name
catch_all- Type
- boolean
- Description
Run the catch-all check for every address and fill the
catch_allfield of the results. In JSON sendtrueorfalse, in a form1or0. Without a plan that includes catch-all detection, the request is refused with403plan_upgrade_required.Available in plans >= small
Response
The job with status queued. Its counts already show how many rows your list has and how many unique valid addresses will be verified; quota.charged and the X-Cost header show the requests charged. When you passed a webhook_url, the response also contains webhook_secret, which signs the webhook. It is only shown here, so store it with the job_id.
A list that costs more than your quota allows is refused with 429 and quota.required before anything is stored or checked (see Billing). Invalid input is answered with 422 and an errors object naming the field (see status codes).
Request
curl https://api.emailvalidation.io/v1/bulk \
-H "apikey: YOUR-API-KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["support@emailvalidation.io", "info@example.com", "INFO@example.com", "not-an-address"], "webhook_url": "https://example.com/hooks/emailvalidation"}'
Response (201)
{
"job_id": "629954218341568512",
"status": "queued",
"source": "json",
"catch_all": false,
"counts": {
"total": 4,
"unique": 2,
"duplicates": 1,
"invalid": 1,
"processed": 0,
"deliverable": 0,
"undeliverable": 0,
"risky": 0,
"unknown": 0
},
"progress": 0,
"quota": {
"charged": 2,
"refunded": 0
},
"webhook": {
"status": "pending",
"attempts": 0
},
"error": null,
"created_at": "2026-10-05T08:13:13+00:00",
"started_at": null,
"finished_at": null,
"expires_at": "2026-11-04T08:13:13+00:00",
"links": {
"self": "https://api.emailvalidation.io/v1/bulk/629954218341568512",
"results": null
},
"webhook_secret": "whsec_27638e506210dd49d706579a3cc6b5dec170ed6d0a044656"
}
The job object
Creating a job, checking it, listing jobs and the webhook all return the job in this form.
- Name
job_id- Type
- string
- Description
The job's id, a string of digits. You need it for every other request about the job.
- Name
status- Type
- string
- Description
queued(accepted, the list is being read),processing,completed(the results are ready) orfailed(seeerror; its charge is refunded, see Billing).
- Name
source- Type
- string
- Description
How the list was submitted:
json,csvortxt.
- Name
catch_all- Type
- boolean
- Description
Whether the catch-all check runs for every address.
- Name
counts- Type
- object
- Description
total: rows of your list, without a header row.unique: unique, syntactically valid addresses; only these are verified and billed.duplicates: rows that repeat an address already in the list (compared without regard to case or surrounding spaces).invalid: rows without a valid address, including empty ones.total=unique+duplicates+invalid.processed,deliverable,undeliverable,riskyandunknowncount unique addresses; an address we could not verify because of a failure on our side counts asunknown.
- Name
progress- Type
- number
- Description
The percentage of the unique addresses checked so far.
- Name
quota- Type
- object
- Description
charged: the requests charged when the job was created (0for sandbox keys).refunded: the requests given back so far (see Billing).
- Name
webhook- Type
- object | null
- Description
nullwithout awebhook_url, otherwise the delivery:status(pending,deliveredorfailed) andattempts.
- Name
error- Type
- string | null
- Description
Why a failed job failed, otherwise
null.
- Name
created_at- Type
- string
- Description
When the job was created (ISO 8601).
started_atis when the list was queued for checking,finished_atwhen the job completed or failed; both arenulluntil then.
- Name
expires_at- Type
- string
- Description
When the job, its list and its results are deleted: 30 days after the job finished (while it runs, 30 days after it was created).
- Name
links- Type
- object
- Description
self: this job's URL.results: the results download once the job is completed, otherwisenull. Both need your API key like every other request.
Check a job
Returns the job with its current status, counts and progress. It is free; polling about once a minute is plenty. On plans with a minute rate limit (the free plan), the job's cost counts against the minute you submitted it in, so wait until the next minute before you check it (see Billing). When status is completed, download the results from links.results.
Jobs belong to your account, so every API key of the account can see them, with one exception: jobs created with a sandbox key are only visible to sandbox keys, and live jobs only to live keys. Any other job_id is answered with 404.
Required attributes
- Name
job_id- Type
- string
- Description
The
job_idreturned when the job was created, as part of the path.
Request
curl https://api.emailvalidation.io/v1/bulk/629954218123464704 \
-H "apikey: YOUR-API-KEY"
Response
{
"job_id": "629954218123464704",
"status": "completed",
"source": "csv",
"catch_all": false,
"counts": {
"total": 5,
"unique": 3,
"duplicates": 1,
"invalid": 1,
"processed": 3,
"deliverable": 2,
"undeliverable": 1,
"risky": 0,
"unknown": 0
},
"progress": 100,
"quota": {
"charged": 3,
"refunded": 0
},
"webhook": {
"status": "delivered",
"attempts": 1
},
"error": null,
"created_at": "2026-10-05T08:13:09+00:00",
"started_at": "2026-10-05T08:13:09+00:00",
"finished_at": "2026-10-05T08:15:41+00:00",
"expires_at": "2026-11-04T08:15:41+00:00",
"links": {
"self": "https://api.emailvalidation.io/v1/bulk/629954218123464704",
"results": "https://api.emailvalidation.io/v1/bulk/629954218123464704/results"
}
}
Download the results
Returns the results of a completed job as a file, with one entry per row of your list in your order (a header row is not counted). Each entry has the same fields the validation endpoint returns. Duplicate rows repeat their address's result, and rows without a valid address get "state": "undeliverable" with "reason": "invalid_format". For a valid address, email holds the address as it was checked (trimmed and in lower case); input is the cell or item as you sent it. Downloading is free and can be repeated until the job expires.
Until the job is completed the request is answered with 409 and the error code job_not_completed; the body carries the job under job. A failed job has no results.
Optional attributes
- Name
format- Type
- string
- Description
json(the default) orcsv.
Response
JSON: job_id and data, a list of entries with row (the row number, starting at 1), input and the 15 result fields.
CSV: your list's columns followed by 15 emailvalidation_* columns, with your delimiter. A CSV without a header row gets the column names column1, column2, …; a JSON or TXT list has a single email column (or the title line of the TXT file). Booleans are written as TRUE and FALSE, an empty catch_all cell means it was not checked. Cells that start with =, +, - or @ get a leading ' so spreadsheet programs do not run them as formulas. The download is named emailvalidation-bulk-<job_id>.csv.
Request
curl -G https://api.emailvalidation.io/v1/bulk/629954218123464704/results \
-d format=csv \
-H "apikey: YOUR-API-KEY" \
-o results.csv
Response (JSON)
{
"job_id": "629954218123464704",
"data": [
{
"row": 1,
"input": "anna@example.com",
"email": "anna@example.com",
"user": "anna",
"tag": "",
"domain": "example.com",
"smtp_check": true,
"mx_found": true,
"did_you_mean": "",
"role": false,
"disposable": false,
"score": 0.8,
"state": "deliverable",
"reason": "valid_mailbox",
"free": false,
"format_valid": true,
"catch_all": null
},
{
"row": 2,
"input": "not-an-address",
"email": "not-an-address",
"user": "",
"tag": "",
"domain": "",
"smtp_check": false,
"mx_found": false,
"did_you_mean": "",
"role": false,
"disposable": false,
"score": 0,
"state": "undeliverable",
"reason": "invalid_format",
"free": false,
"format_valid": false,
"catch_all": null
}
]
}
Response (CSV)
id,email,name,emailvalidation_email,emailvalidation_user,emailvalidation_domain,emailvalidation_tag,emailvalidation_did_you_mean,emailvalidation_smtp_check,emailvalidation_mx_found,emailvalidation_format_valid,emailvalidation_free,emailvalidation_role,emailvalidation_disposable,emailvalidation_catch_all,emailvalidation_score,emailvalidation_state,emailvalidation_reason
1,anna@example.com,Anna,anna@example.com,anna,example.com,,,TRUE,TRUE,TRUE,FALSE,FALSE,FALSE,,0.8,deliverable,valid_mailbox
2,not-an-address,Carl,not-an-address,,,,,FALSE,FALSE,FALSE,FALSE,FALSE,FALSE,,0,undeliverable,invalid_format
List jobs
Returns your account's jobs, newest first, in pages. As with a single job, sandbox keys list the sandbox jobs and live keys the live jobs. Free.
Optional attributes
- Name
limit- Type
- integer
- Description
Jobs per page,
1to100. Defaults to25.
- Name
before- Type
- string
- Description
The
job_idof the last job of the previous page, to get the next (older) page.
Response
data is a list of jobs. has_more is true when older jobs follow.
Request
curl -G https://api.emailvalidation.io/v1/bulk \
-d limit=10 \
-H "apikey: YOUR-API-KEY"
Response
{
"data": [
{
"job_id": "629954218123464704",
"status": "completed",
...
}
],
"has_more": false
}
Delete a job
Deletes the job together with its stored list and results, whatever its status. Free.
Addresses that were not verified yet are refunded: all of them for a queued job, and for a running job the addresses not checked yet plus those we could not verify because of a failure on our side. Deleting a completed job refunds nothing, and a failed job was already refunded.
Required attributes
- Name
job_id- Type
- string
- Description
The
job_id, as part of the path.
Request
curl -X DELETE https://api.emailvalidation.io/v1/bulk/629954218123464704 \
-H "apikey: YOUR-API-KEY"
Response
{
"job_id": "629954218123464704",
"deleted": true
}
Webhooks
When you pass a webhook_url, we send one POST to it once the job has completed or failed. The body is the job as GET /v1/bulk/{job_id} returns it, together with the event. It never contains addresses or results: download those with your API key.
Webhook body
{
"event": "bulk.completed",
"job": {
"job_id": "629954218123464704",
"status": "completed",
"counts": { "total": 5, "unique": 3, "duplicates": 1, "invalid": 1, "processed": 3, "deliverable": 2, "undeliverable": 1, "risky": 0, "unknown": 0 },
...
"links": {
"self": "https://api.emailvalidation.io/v1/bulk/629954218123464704",
"results": "https://api.emailvalidation.io/v1/bulk/629954218123464704/results"
}
}
}
The event is bulk.completed or bulk.failed. The request carries these headers:
| Header | Content |
|---|---|
X-Emailvalidation-Event | bulk.completed or bulk.failed |
X-Emailvalidation-Delivery | <job_id>.<status>, the same on every attempt; use it to ignore repeats |
X-Emailvalidation-Timestamp | Unix time in seconds when this attempt was signed |
X-Emailvalidation-Signature | v1= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the job's webhook_secret |
Verify the signature
Check the signature before you trust a webhook. The secret is different for every job: look it up by the job_id (the part of X-Emailvalidation-Delivery before the dot). Compute the HMAC over the raw request body exactly as received, compare it in constant time, and reject timestamps older than a few minutes.
Webhook receiver
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_EMAILVALIDATION_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_EMAILVALIDATION_SIGNATURE'] ?? '';
$jobId = strtok($_SERVER['HTTP_X_EMAILVALIDATION_DELIVERY'] ?? '', '.');
$secret = findWebhookSecret($jobId); // the webhook_secret you stored for this job
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// $event['event'] is bulk.completed or bulk.failed, $event['job'] the job
http_response_code(200);
Delivery and retries
- Answer with any
2xxstatus within 10 seconds. Do the work after you answered, for example by queuing the download. - Any other status, a redirect (we do not follow redirects) or no answer counts as a failed attempt. We try up to 6 times in about 2.5 hours: right away, then after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours. Each attempt has a new timestamp and signature.
- A delivered webhook is not sent again. The job's
webhook.statusshows whether it wasdeliveredor hasfailed, so you can fall back to polling. - The
webhook_urlmust be anhttpsURL on a public host. We check it when you submit the list and again before every attempt, and connect only to the address we checked. A host that does not resolve at that moment is retried; a host that resolves to a private or reserved address is given up on.
CSV and TXT files
- TXT: one address per line. Empty lines are skipped, and so is a first line that only says
email(or a similar column title). - CSV: the delimiter (
,,;,|or tab) is detected, and so is a header row: a first row with a known column title such asemail,nameorid, or a first row without any address above rows with addresses whose cell in the address column reads like a column title (such asE-MailorWork Mail: it has letters, no@and does not end like a domain). Empty lines are skipped. A row may have at most 500 columns. - The address column is the one you name in
email_column. Without it, it is the column titled likeemail(or containingemailore-mailin its title), and otherwise the column with the most addresses in the first 100 rows. - Rows count as in your file: every row after the header row is one entry of the results, in the same order, even when its address cell is empty or invalid. So
Bob,orBob,bob-at-example.comas the first row of a file without a header row is checked like any other row, but a first row such asBob,Smithis read as the header. Give your file a header row to be sure.
The results CSV keeps all of your columns and your delimiter and appends the emailvalidation_* columns, so you can import it back into the tool the list came from. Check two things before you do: cells that start with =, +, - or @ (such as phone numbers in +49… format) get a leading ', and a file without a header row gets one (column1, column2, …).
Limits
| Limit | Value |
|---|---|
| Rows per list | 100,000 (1,000 with a sandbox key) |
File size, or all emails items together | 25 MB |
Length of one emails item | 1,000 characters |
| Columns per CSV row | 500 |
The list is read while you wait for the 201. A list that cannot be read in time is refused with 422 and the message "The list could not be read in time. Please split it into smaller lists."
Billing
- Charged when you submit: one request of your quota per unique, syntactically valid address. The
X-Costheader of the201andquota.chargedshow the amount. Addresses are compared without regard to case and surrounding spaces, soAnna@Example.comandanna@example.comcount once. - Free: duplicates, rows without a valid address, checking a job, listing jobs, downloading results and deleting jobs. A list without any valid address is accepted and costs nothing.
- Refunded: addresses we could not verify because of a failure on our side (after several retries) when the job completes; they appear as
unknownwith the reasontimeout. Deleting a job refunds the addresses not verified yet, and a failed job refunds its whole charge. Refunds go back to your current quota and cannot raise it above its size: when a job submitted before your monthly quota reset is refunded after it, you get back at most the requests you have used since the reset.quota.refundedshows the requests given back. - Checked before anything else: a list is only accepted if your monthly quota (or, while a billing issue is being resolved, your grace quota) has more requests left than the job costs. Otherwise the request is answered with
429(quota_exceeded, orgrace_quota_exceededon a grace quota), andquota.requiredin the body says how many requests the job needs. Nothing is stored, checked or billed in that case. Bulk jobs do not use the overage allowance. - Minute limit: on plans with a minute rate limit (the free plan), a job counts its cost against the minute's limit too, so after submitting a larger list, further requests in the same minute, including status checks, can be answered with
429until the minute is over. - When your quota is used up, requests with your key are answered with
429until the quota resets or you upgrade, and that includes status checks and result downloads of bulk jobs. Download the results you need before your quota runs out.
Data retention and privacy
- Your list and the results are encrypted with AES-256 before they are stored, on top of our storage provider's encryption at rest. While a job runs, its addresses are kept encrypted in our database and deleted when the job finishes. The
webhook_urland thewebhook_secretare stored encrypted too. - The results are only available through the API, with an API key of your account. There are no public download links, and the webhook never contains addresses or results.
- A job, its list and its results are deleted 30 days after the job finished (see
expires_at), or right away when you delete the job. - Jobs created with sandbox keys and live jobs are kept apart: a sandbox key cannot read or delete live jobs, and a live key cannot see sandbox jobs.
Testing with sandbox keys
Sandbox keys work with bulk jobs too. A sandbox job is free (quota.charged is 0), accepts up to 1,000 rows and never contacts a mail server: every address gets the made-up result the test addresses define, and any other address is deliverable. Sandbox jobs usually finish within a minute, and their webhooks are delivered and signed like live ones, so you can test your receiver with them. Sandbox keys only see sandbox jobs.
MCP
The MCP server offers the bulk endpoints as tools, except the results download: createBulkJob (with the emails list; file uploads are not available over MCP), getBulkJob, listBulkJobs and deleteBulkJob. Download the results of a completed job from links.results over the REST API.