Bulk jobs API

Bulk jobs check whole lists: you upload the addresses, we remove duplicates, check every unique address and give you a CSV with the results. Bulk endpoints need a key with the bulk scope.

Create a job

POST /v1/bulk accepts either:

  • a CSV file (Content-Type: text/csv) with any columns, or a plain-text file (text/plain) with one address per line, up to 100 MB and 1,000,000 rows; or
  • a JSON list (application/json): {"emails": [...]} with up to 100,000 addresses and 4 MB (send larger lists as CSV).

The request must carry a Content-Length header (most HTTP clients add it when you send a whole file; streamed uploads without a length get 411 length_required). A larger body, or a JSON list of more than 100,000 addresses, gets 413 payload_too_large. Files larger than 100 MB, up to 1 GB, can be uploaded in the dashboard.

Until an account's first purchase it may have at most 2 lists that are analyzing or ready (jobs that start on their own count while they are analysed); another upload gets 409 conflict until one of them starts or is cancelled. Stored lists may take up to 2 GB without a purchase and 20 GB with one; an upload over that quota gets 413 payload_too_large.

Query parameterDescription
columnThe email column: a 1-based index or the header name. Detected when omitted.
has_headertrue or false. Detected when omitted.
starttrue (default) starts as soon as the file is analysed; false waits so you can check the cost first.
webhook_endpoint_idA webhook endpoint registered in the dashboard to notify when the job ends (it gets the event even if it did not subscribe to it).

curl

# A CSV file with a header row; the email column is found automatically.
curl https://api.email-validation.org/v1/bulk \
  -H "Authorization: Bearer $EV_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @customers.csv

# Or a JSON list, without starting it yet:
curl "https://api.email-validation.org/v1/bulk?start=false" \
  -H "Authorization: Bearer $EV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["accepted@sandbox.email-validation.org", "catch_all@sandbox.email-validation.org"]}'

JavaScript

import { readFile } from 'node:fs/promises';

const csv = await readFile('customers.csv');
const res = await fetch('https://api.email-validation.org/v1/bulk?column=email&has_header=true', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.EV_API_KEY}`,
    'Content-Type': 'text/csv',
  },
  body: csv,
});
const job = await res.json();
if (!res.ok) throw new Error(`${job.error.code}: ${job.error.message}`);
console.log(job.id, job.status);

Python

import os

import requests

with open("customers.csv", "rb") as f:
    resp = requests.post(
        "https://api.email-validation.org/v1/bulk",
        params={"column": "email", "has_header": "true"},
        headers={"Authorization": f"Bearer {os.environ['EV_API_KEY']}", "Content-Type": "text/csv"},
        data=f.read(),
        timeout=120,
    )
job = resp.json()
if not resp.ok:
    raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
print(job["id"], job["status"])

The answer is 202 Accepted with the new job and a Location header:

{
  "id": "job_3Fv8QpL2xM7nC1rT9kW4yB",
  "status": "analyzing",
  "filename": "newsletter-2026-11.csv",
  "total_rows": null,
  "unique_emails": null,
  "duplicates": null,
  "syntax_invalid": null,
  "local_answered": null,
  "processed": 0,
  "credits_reserved": 0,
  "credits_charged": 0,
  "counts": {},
  "error_code": null,
  "webhook_endpoint_id": null,
  "created_at": "2026-11-02T09:30:00.000Z",
  "started_at": null,
  "finished_at": null,
  "expires_at": "2026-12-02T09:30:00.000Z"
}

What happens next

Job lifecycle:

StatusMeaning
analyzingReading the file, removing duplicates and answering malformed addresses.
readyAnalysis done. Waiting for a start (or for credits when error_code is insufficient_credits).
queuedStarted and waiting for its turn.
runningAddresses are being checked.
cancellingA cancel was requested; work in flight is finishing.
finalizingWriting the result files.
completedResults are ready to download.
failedThe job could not finish; see error_code. Unused credits are returned.
cancelledThe job was cancelled. Unused credits are returned.
expiredThe files were deleted after 30 days.
  1. Analysis. We read the file, normalise addresses, drop duplicates (they get the same result as the first occurrence) and answer malformed and test addresses for free.
  2. Start. One credit is held for every unique address that still needs a check. If your balance is too low, the job stays ready with error_code: insufficient_credits; buy credits and start it again. The credits count toward the daily credit cap of the API key that created the job (or of the key that starts it): when the cap does not cover it, the job stays ready with error_code: key_daily_cap (429 on start). A list can be started until 29 days after upload. A job that stays ready with an error_code never starts by itself, so stop polling it.
  3. Checking. Every unique address is checked once; there is no second pass, so a job finishes as soon as the last check returns. Re-check unknown rows later if you need a definite answer.
  4. Results. When the job is completed, credits for results that ended unknown are returned and the result files are ready. A job that fails after its addresses were checked is charged for those checks, like a cancelled one; a file that cannot be read fails for free.

Files and results are deleted 30 days after upload (a job that finishes in its last day keeps its results for 7 days after it finishes). The file of a failed or cancelled job is deleted at once.

Follow, start, cancel

RequestDoes
GET /v1/bulk/{id}Returns the job with processed, counts and the credits held and charged.
POST /v1/bulk/{id}/startStarts a job that is ready (402 when credits are short, 429 key_daily_cap past the key's daily cap, 409 in other states).
DELETE /v1/bulk/{id}Cancels the job. Work in flight finishes, unused credits are returned and the files are deleted.

Poll every few seconds, or register a webhook and wait for bulk.completed.

curl

JOB=job_3Fv8QpL2xM7nC1rT9kW4yB

# Progress and counts
curl https://api.email-validation.org/v1/bulk/$JOB -H "Authorization: Bearer $EV_API_KEY"

# Start a job that is waiting in "ready"
curl -X POST https://api.email-validation.org/v1/bulk/$JOB/start -H "Authorization: Bearer $EV_API_KEY"

# Download only deliverable and risky rows, with the original columns
curl -o results.csv "https://api.email-validation.org/v1/bulk/$JOB/results?filter=deliverable,risky" \
  -H "Authorization: Bearer $EV_API_KEY"

# Cancel (unused credits are returned)
curl -X DELETE https://api.email-validation.org/v1/bulk/$JOB -H "Authorization: Bearer $EV_API_KEY"

JavaScript

const headers = { Authorization: `Bearer ${process.env.EV_API_KEY}` };
const done = new Set(['completed', 'failed', 'cancelled', 'expired']);

async function waitForJob(id) {
  for (;;) {
    const res = await fetch(`https://api.email-validation.org/v1/bulk/${id}`, { headers });
    const job = await res.json();
    if (!res.ok) throw new Error(`${job.error.code}: ${job.error.message}`);
    if (done.has(job.status)) return job;
    console.log(`${job.processed}/${job.unique_emails ?? '?'} done`);
    await new Promise((resolve) => setTimeout(resolve, 5000));
  }
}

const job = await waitForJob('job_3Fv8QpL2xM7nC1rT9kW4yB');
if (job.status === 'completed') {
  const res = await fetch(`https://api.email-validation.org/v1/bulk/${job.id}/results?filter=deliverable`, { headers });
  console.log(await res.text());
}

Python

import os
import time

import requests

API = "https://api.email-validation.org"
HEADERS = {"Authorization": f"Bearer {os.environ['EV_API_KEY']}"}
DONE = {"completed", "failed", "cancelled", "expired"}


def wait_for_job(job_id: str) -> dict:
    while True:
        job = requests.get(f"{API}/v1/bulk/{job_id}", headers=HEADERS, timeout=10).json()
        if job["status"] in DONE:
            return job
        print(job["processed"], "of", job["unique_emails"], "done")
        time.sleep(5)


job = wait_for_job("job_3Fv8QpL2xM7nC1rT9kW4yB")
if job["status"] == "completed":
    with requests.get(
        f"{API}/v1/bulk/{job['id']}/results", params={"filter": "all"}, headers=HEADERS, stream=True, timeout=60
    ) as r:
        r.raise_for_status()
        with open("results.csv", "wb") as f:
            for chunk in r.iter_content(chunk_size=1 << 20):
                f.write(chunk)

Download results

GET /v1/bulk/{id}/results streams a UTF-8 CSV once the job is completed (before that: 409 conflict).

ParameterDescription
filterall (default), one status, or several separated by commas, e.g. deliverable,risky.
columnsoriginal (default) keeps every column of your file; email_only returns the address and our columns.
escape_formulastrue (default) neutralises cells from your file that start with =, +, - or @ (also after a comma, semicolon, tab or line break inside a cell, for spreadsheets set to another list separator), so spreadsheets do not run them. Our own columns and the address column are always neutralised.

To resume an interrupted download with a Range header, ask for a stored file: columns=original with filter=all (with or without escape_formulas), or with a single status and escape_formulas=false. Those answers carry Accept-Ranges: bytes, and a range request gets 206 Partial Content (or 416 when the range lies outside the file). Every other answer carries Accept-Ranges: none and returns the whole file with 200, whatever the Range header says, so check for 206 before you append.

Our columns are added after yours:

ColumnMeaning
ev_statusStatus: deliverable, undeliverable, risky or unknown.
ev_reasonReason for the status.
ev_actionRecommended action.
ev_free_emailtrue when the domain is a free consumer mailbox service.
ev_role_accounttrue for team or function addresses.
ev_disposabletrue for throwaway inbox services.
ev_catch_all_domaintrue when the domain accepts mail for any address.
ev_did_you_meanSuggested correction for a likely typo.
ev_mx_foundtrue when the domain publishes mail records.
ev_mx_recordPreferred mail server of the domain.
ev_smtp_providerMail provider of the domain (our list; other when unnamed).
ev_domain_age_daysDomain age in days, when known.

Job object

FieldTypeDescription
idstringBulk job id.
statusBulkJobStatus
filenamestring or null
total_rowsinteger or nullData rows in the file (header excluded).
unique_emailsinteger or nullDistinct addresses after normalisation.
duplicatesinteger or nullRows that repeat an address seen earlier in the file.
syntax_invalidinteger or nullAddresses that are not well formed (answered for free).
local_answeredinteger or nullAddresses answered without a mail-server check (malformed and sandbox addresses), all free.
processedintegerUnique addresses with a final result so far.
credits_reservedintegerCredits held when the job started: one per unique address that needs a check.
credits_chargedintegerCredits actually used. When the job ends, the difference to credits_reserved is returned.
countsobjectResults so far by status.
error_codestring or nullWhy a job failed or is waiting in ready, for example insufficient_credits (the job stays ready) or invalid_csv. Every value: https://email-validation.org/docs/api/bulk/#errors
webhook_endpoint_idstring or nullRegistered webhook endpoint notified about this job.
created_atstring (date-time)When the job was created.
started_atstring or null (date-time)
finished_atstring or null (date-time)
expires_atstring (date-time)When the uploaded file and the results are deleted (30 days).

Limits

APIDashboard
FormatsCSV, plain text, JSON listCSV, XLSX, plain text
Largest upload100 MB, or 100,000 addresses as JSON1 GB
Rows per file1,000,0001,000,000
Columns per row512512
Size of one row64 KB64 KB
Kept for30 days30 days

A file with too many rows fails with error_code: too_many_rows; one with a row that is too long, too many columns or an unclosed quote fails with error_code: invalid_csv. Neither costs anything.

Error codes

A job that is failed, or that waits in ready because it could not start, says why in error_code (the bulk.failed webhook carries the same value):

error_codeJob statusWhen it happensWhat to do
invalid_csvfailedThe file could not be read: an unclosed quote, a row over 64 KB or more than 512 columns.Fix the file (or export it again as CSV) and upload it again. Nothing was charged.
too_many_rowsfailedThe file has more than 1,000,000 rows.Split the list into smaller files. Nothing was charged.
empty_filefailedThe file has no data rows.Check that you uploaded the right file. Nothing was charged.
email_column_not_foundfailedThe chosen email column does not exist in the file.Upload again with the right column (a 1-based index or the header name). Nothing was charged.
upload_missingfailedThe uploaded file was no longer available, for example because it expired before the job ran.Upload the file again. Unused credits are returned.
pipeline_mismatchfailedThe results could not be matched to your file safely, for example after a service update during the job.Upload the file again. Unused credits are returned.
missing_resultsfailedSome results could not be assembled into the result file.Upload the file again. Unused credits are returned; contact support if it repeats.
internal_errorfailedSomething failed on our side.Upload the file again. Unused credits are returned; contact support if it repeats.
insufficient_creditsreadyThe balance was lower than the number of unique addresses that need a check, so the job did not start.Buy credits, then start the job with POST /v1/bulk/{id}/start.
upstream_capacityreadyValidation capacity was too low to admit the job right now, so it did not start.Start the job again later with POST /v1/bulk/{id}/start.
account_suspendedreadyThe account is suspended, so the job did not start.Contact support.
billing_holdreadyA payment on the account is under review, so the job did not start.Contact support.
invalid_keyreadyThe account could not be loaded when the job tried to start.Start the job again in a few minutes; contact support if it repeats.

Updated · Maintained by the email-validation.org team