# Bulk jobs API

URL: https://email-validation.org/docs/api/bulk/
Updated: 2026-10-20

> Documentation index: https://email-validation.org/llms.txt

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](https://email-validation.org/docs/bulk/).

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**

```bash
# 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**

```js
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**

```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:

```json
{
  "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. |

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](https://email-validation.org/docs/authentication/#limits) 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

| 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](https://email-validation.org/docs/webhooks/) and wait for `bulk.completed`.

**curl**

```bash
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**

```js
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**

```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](https://email-validation.org/docs/api-reference/#schema-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](https://email-validation.org/docs/webhooks/) 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. |
