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 parameter | Description |
|---|---|
column | The email column: a 1-based index or the header name. Detected when omitted. |
has_header | true or false. Detected when omitted. |
start | true (default) starts as soon as the file is analysed; false waits so you can check the cost first. |
webhook_endpoint_id | A 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:
| Status | Meaning |
|---|---|
analyzing | Reading the file, removing duplicates and answering malformed addresses. |
ready | Analysis done. Waiting for a start (or for credits when error_code is insufficient_credits). |
queued | Started and waiting for its turn. |
running | Addresses are being checked. |
cancelling | A cancel was requested; work in flight is finishing. |
finalizing | Writing the result files. |
completed | Results are ready to download. |
failed | The job could not finish; see error_code. Unused credits are returned. |
cancelled | The job was cancelled. Unused credits are returned. |
expired | The files were deleted after 30 days. |
- 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.
- Start. One credit is held for every unique address that still needs a check. If your balance is too low, the job stays
readywitherror_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 staysreadywitherror_code: key_daily_cap(429on start). A list can be started until 29 days after upload. A job that staysreadywith anerror_codenever starts by itself, so stop polling it. - 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
unknownrows later if you need a definite answer. - Results. When the job is
completed, credits for results that endedunknownare 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
| Request | Does |
|---|---|
GET /v1/bulk/{id} | Returns the job with processed, counts and the credits held and charged. |
POST /v1/bulk/{id}/start | Starts 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).
| Parameter | Description |
|---|---|
filter | all (default), one status, or several separated by commas, e.g. deliverable,risky. |
columns | original (default) keeps every column of your file; email_only returns the address and our columns. |
escape_formulas | true (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:
| Column | Meaning |
|---|---|
ev_status | Status: deliverable, undeliverable, risky or unknown. |
ev_reason | Reason for the status. |
ev_action | Recommended action. |
ev_free_email | true when the domain is a free consumer mailbox service. |
ev_role_account | true for team or function addresses. |
ev_disposable | true for throwaway inbox services. |
ev_catch_all_domain | true when the domain accepts mail for any address. |
ev_did_you_mean | Suggested correction for a likely typo. |
ev_mx_found | true when the domain publishes mail records. |
ev_mx_record | Preferred mail server of the domain. |
ev_smtp_provider | Mail provider of the domain (our list; other when unnamed). |
ev_domain_age_days | Domain age in days, when known. |
Job object
| Field | Type | Description |
|---|---|---|
id | string | Bulk job id. |
status | BulkJobStatus | |
filename | string or null | |
total_rows | integer or null | Data rows in the file (header excluded). |
unique_emails | integer or null | Distinct addresses after normalisation. |
duplicates | integer or null | Rows that repeat an address seen earlier in the file. |
syntax_invalid | integer or null | Addresses that are not well formed (answered for free). |
local_answered | integer or null | Addresses answered without a mail-server check (malformed and sandbox addresses), all free. |
processed | integer | Unique addresses with a final result so far. |
credits_reserved | integer | Credits held when the job started: one per unique address that needs a check. |
credits_charged | integer | Credits actually used. When the job ends, the difference to credits_reserved is returned. |
counts | object | Results so far by status. |
error_code | string or null | Why 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_id | string or null | Registered webhook endpoint notified about this job. |
created_at | string (date-time) | When the job was created. |
started_at | string or null (date-time) | |
finished_at | string or null (date-time) | |
expires_at | string (date-time) | When the uploaded file and the results are deleted (30 days). |
Limits
| API | Dashboard | |
|---|---|---|
| Formats | CSV, plain text, JSON list | CSV, XLSX, plain text |
| Largest upload | 100 MB, or 100,000 addresses as JSON | 1 GB |
| Rows per file | 1,000,000 | 1,000,000 |
| Columns per row | 512 | 512 |
| Size of one row | 64 KB | 64 KB |
| Kept for | 30 days | 30 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_code | Job status | When it happens | What to do |
|---|---|---|---|
invalid_csv | failed | The 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_rows | failed | The file has more than 1,000,000 rows. | Split the list into smaller files. Nothing was charged. |
empty_file | failed | The file has no data rows. | Check that you uploaded the right file. Nothing was charged. |
email_column_not_found | failed | The 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_missing | failed | The uploaded file was no longer available, for example because it expired before the job ran. | Upload the file again. Unused credits are returned. |
pipeline_mismatch | failed | The 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_results | failed | Some results could not be assembled into the result file. | Upload the file again. Unused credits are returned; contact support if it repeats. |
internal_error | failed | Something failed on our side. | Upload the file again. Unused credits are returned; contact support if it repeats. |
insufficient_credits | ready | The 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_capacity | ready | Validation 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_suspended | ready | The account is suspended, so the job did not start. | Contact support. |
billing_hold | ready | A payment on the account is under review, so the job did not start. | Contact support. |
invalid_key | ready | The account could not be loaded when the job tried to start. | Start the job again in a few minutes; contact support if it repeats. |