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.


How it works

  1. Submit the list with POST /v1/bulk: a JSON array of addresses, or a CSV or TXT file. The answer (201) is the job: its job_id, the counts of your list and the requests charged.
  2. Wait until the job has finished. Either poll GET /v1/bulk/{job_id} about once a minute until status is completed (or failed), or pass a webhook_url when you submit the list and we send it a signed POST once the job has finished. On the free plan, wait until the minute after you submitted the list before you check it (see Billing).
  3. Download the results with GET /v1/bulk/{job_id}/results, as JSON or, with format=csv, as CSV.
  4. 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"

POST/v1/bulk

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_format and 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 .txt is 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 (1 to 500) of the column with the addresses. Detected when you leave it out.

  • Name
    webhook_url
    Type
    string
    Description

    An https URL on a public host, without user name or password, that receives one signed POST when 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_all field of the results. In JSON send true or false, in a form 1 or 0. Without a plan that includes catch-all detection, the request is refused with 403 plan_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

POST
/v1/bulk
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) or failed (see error; its charge is refunded, see Billing).

  • Name
    source
    Type
    string
    Description

    How the list was submitted: json, csv or txt.

  • 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, risky and unknown count unique addresses; an address we could not verify because of a failure on our side counts as unknown.

  • 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 (0 for sandbox keys). refunded: the requests given back so far (see Billing).

  • Name
    webhook
    Type
    object | null
    Description

    null without a webhook_url, otherwise the delivery: status (pending, delivered or failed) and attempts.

  • 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_at is when the list was queued for checking, finished_at when the job completed or failed; both are null until 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, otherwise null. Both need your API key like every other request.


GET/v1/bulk/{job_id}

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_id returned when the job was created, as part of the path.

Request

GET
/v1/bulk/{job_id}
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"
    }
}

GET/v1/bulk/{job_id}/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) or csv.

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

GET
/v1/bulk/{job_id}/results
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

GET/v1/bulk

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, 1 to 100. Defaults to 25.

  • Name
    before
    Type
    string
    Description

    The job_id of 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

GET
/v1/bulk
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/v1/bulk/{job_id}

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

DELETE
/v1/bulk/{job_id}
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:

HeaderContent
X-Emailvalidation-Eventbulk.completed or bulk.failed
X-Emailvalidation-Delivery<job_id>.<status>, the same on every attempt; use it to ignore repeats
X-Emailvalidation-TimestampUnix time in seconds when this attempt was signed
X-Emailvalidation-Signaturev1= 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 2xx status 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.status shows whether it was delivered or has failed, so you can fall back to polling.
  • The webhook_url must be an https URL 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 as email, name or id, or a first row without any address above rows with addresses whose cell in the address column reads like a column title (such as E-Mail or Work 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 like email (or containing email or e-mail in 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, or Bob,bob-at-example.com as the first row of a file without a header row is checked like any other row, but a first row such as Bob,Smith is 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

LimitValue
Rows per list100,000 (1,000 with a sandbox key)
File size, or all emails items together25 MB
Length of one emails item1,000 characters
Columns per CSV row500

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-Cost header of the 201 and quota.charged show the amount. Addresses are compared without regard to case and surrounding spaces, so Anna@Example.com and anna@example.com count 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 unknown with the reason timeout. 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.refunded shows 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, or grace_quota_exceeded on a grace quota), and quota.required in 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 429 until the minute is over.
  • When your quota is used up, requests with your key are answered with 429 until 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_url and the webhook_secret are 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.