# How to bulk-validate a CSV of email addresses

_2026-09-25 · Boundstone (https://boundstone.io/blog/bulk-email-validation-csv)_


You have a CSV of email addresses — a signup export, a list you bought and now regret, a decade of newsletter sign-ups — and you want to know which rows are worth sending to before your bounce rate tells the story for you. That is the whole job of bulk email validation: a CSV in, a verdict per row out. Here is how to do it against the Boundstone API in two requests, and, just as importantly, exactly what each row does and does not get checked.

## First, what you can do without an API

If your CSV problem is only *syntax* — malformed addresses, a missing `@`, a typo'd TLD — you do not need a service for that. A few lines of your standard library will read the file and run a regex. Our walkthrough in [validate an email address in Python](/blog/validate-email-python) covers the local version, and the keyless [email validator tool](/tools/email-validator) will spot-check a single address in the browser with no signup.

Syntax is where most languages stop, though. Knowing an address is *shaped* correctly tells you nothing about whether the domain can receive mail, whether it is a disposable burner, or whether it is `info@` / `sales@` — a role account no single human reads. That is the layer the bulk endpoint adds, once per row, across the whole file.

## What each row actually gets checked

Every row gets the exact same checks as the single `/v1/verify/email` endpoint. No more, no less. For a valid address:

```json
"checks": {
  "performed": ["syntax", "mx", "disposable_list", "role_list"],
  "not_performed": ["smtp_mailbox", "catch_all"]
}
```

Read `not_performed` as a feature, not a disclaimer. Boundstone does not open an SMTP conversation with the recipient's mail server, and it does not try to defeat catch-all domains. Those probes are slow, easy to get wrong, and the kind of thing that gets your sending IP blocked. What you get instead is fast and stated plainly: valid syntax, a live MX-record lookup, a disposable-domain check, and a role-account check. The results also carry a `free_provider` flag (gmail.com, outlook.com, and the like) — useful context, though note it is a returned field, not one of the `checks.performed`.

If you need mailbox-level liveness, that is a real thing we do not claim to do today. Handle that expectation in your own pipeline rather than pretending a green row means "inbox exists."

## Post the CSV, get a job_id

Send the file as a raw CSV body to `/v1/bulk/email`. One column of addresses is all it needs.

```bash
curl -X POST https://api.boundstone.io/v1/bulk/email \
  -H "Authorization: Bearer bs_live_YOUR_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @contacts.csv
```

You get back `HTTP 202 Accepted` and a job id — the request returns immediately instead of holding the connection open while thousands of MX lookups run:

```json
{ "job_id": "8f3c1a20-..." }
```

## Fetch the results

Pull the finished results as a CSV from the job's results path. Give a large file a moment to drain before you fetch.

```bash
curl https://api.boundstone.io/v1/bulk/8f3c1a20-.../results.csv \
  -H "Authorization: Bearer bs_live_YOUR_KEY" \
  -o results.csv
```

You get one row per input address, carrying the same fields the single endpoint returns — `valid_syntax`, `domain`, `mx_found`, `disposable`, `role_account`, `free_provider`. Every row ran the same checks contract shown above, so `mx_found` and `disposable` are the columns you filter on to decide what actually ships.

## Credits, caps, and refunds

A few numbers that decide how you batch:

- **The free tier caps a job at 250 rows.** Paid plans go up to 10,000 rows per job. Split larger files into multiple jobs.
- **Credits are reserved per row at submit** — a 400-row file reserves 400 credits up front.
- **Rows that error are refunded.** If a row can't be processed, you get that credit back. You pay for work done, not for lines in a file.

The free tier is 250 credits a month, no card, and credits never expire — enough to validate a small list or prove the pipeline out before you commit to anything.

## No code? Upload it in the dashboard

The same bulk engine is wired into the dashboard. Drop a CSV into the upload box, or paste addresses one per line, and you get the same job with the same per-row checks and the same downloadable `results.csv`. Same caps, same refunds, no curl required.

## The short version

- **Two requests:** `POST /v1/bulk/email` with the raw CSV → `202` + `job_id`; then `GET /v1/bulk/:id/results.csv`.
- **Each row gets** syntax, MX, disposable, and role checks — and, honestly, *not* SMTP mailbox or catch-all.
- **250 rows/job free, 10,000 paid;** credits reserved per row, refunded on error.
- **A clean list is the point.** Dropping dead domains and disposables before you send is the cheapest way to [reduce your bounce rate](/blog/reduce-email-bounce-rate) — and to stop the addresses that stay from paying for the ones that don't.

Validate the list, filter on `mx_found` and `disposable`, and know exactly what a clean row did and did not promise.
