# Real-time vs batch email validation: which should you use?

_2026-09-30 · Boundstone (https://boundstone.io/blog/real-time-vs-batch-validation)_


You have two situations and one question. There is a signup form where a new address arrives one at a time, and there is a spreadsheet of forty thousand addresses you exported last year and never touched again. **Real-time vs batch email validation** is not really a contest. It is two shapes of the same check, and which one you reach for depends on a single thing: when the addresses show up. This post shows both calls side by side, says when each fits, and is plain about the one thing neither of them does.

Before either of them, a note on doing less. A malformed address — no `@`, no dot in the domain — you can catch in your own code without spending anything. If that is all you need, the [validate email in Python](/blog/validate-email-python) walkthrough covers the stdlib-level syntax pass, and for a one-off human check the free [email validator tool](/tools/email-validator) takes a single address with no signup. Reach for the API when you want the domain actually checked for mail records, not just the string.

## The two shapes

Real-time is one address, one synchronous call to `/v1/verify/email`, run at the point of capture. It costs 1 credit and the answer comes back on the request — fast enough to sit inline in a form submit.

Batch is a CSV of addresses sent to `/v1/bulk/email`, processed as a job. Credits are reserved per row and refunded for rows that error.

Same endpoint family, same checks, same `not_performed` list. The only real difference is timing: one address as it arrives, or a file of addresses you already have.

## Real-time: validate at the point of capture

The point of capture is a signup form, a checkout, an API intake — anywhere one address shows up and you want an answer before you write it to your database.

```bash
curl -X POST https://api.boundstone.io/v1/verify/email \
  -H "Authorization: Bearer bs_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'
```

```json
{
  "valid_syntax": true,
  "domain": "example.com",
  "mx_found": true,
  "disposable": false,
  "role_account": false,
  "free_provider": false,
  "checks": {
    "performed": ["syntax", "mx", "disposable_list", "role_list"],
    "not_performed": ["smtp_mailbox", "catch_all"]
  }
}
```

One thing that matters here more than anywhere else: the `bs_live_` key is a server-side secret. A signup form runs in the browser, so call the API from your own backend route and never ship the key to the client.

```js
// server route — the key stays on your infrastructure
const res = await fetch("https://api.boundstone.io/v1/verify/email", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.BOUNDSTONE_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email }),
});
const result = await res.json();
```

## Batch: validate a list you already have

A batch job is for the addresses that arrived before you had validation — an export, a CRM dump, a list you inherited. You send raw CSV and get back a job.

```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 @list.csv
```

That returns `HTTP 202` with a `job_id`. When it finishes, pull the results:

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

The free tier caps a job at 250 rows; paid jobs go up to 10,000. Credits are reserved per row up front and refunded for any row that errors, so a malformed line in the middle of your file does not quietly cost you. If you would rather not write code at all, the dashboard has a CSV upload and a paste box that run the same job. The [bulk email validation from a CSV](/blog/bulk-email-validation-csv) post walks the whole workflow end to end.

## The result is identical — that's the point

Whichever mode you use, the checks are the same. Every result carries:

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

A "valid" from a batch of forty thousand means exactly what a "valid" from a live signup means — there is no quieter, cheaper version of the check for lists. (One field to read carefully: `free_provider` is returned so you can see a Gmail or Outlook address for what it is, but it is not a member of `performed`. It is a flag, not a verdict.)

## When each fits, and why you use both

Real-time is the front door: run it as new addresses arrive so bad data never lands in the first place. Batch is the backlog: run it once over the list you already have to find the dead domains and disposables sitting in it today.

Most teams use both. Batch-clean the existing list, then wire real-time into the signup path so it stays clean. Same checks on both ends means the list you cleaned and the addresses you add next are held to one standard, not two.

## What neither one does

Both modes stop at the same honest line, and it is worth reading `not_performed` out loud: `smtp_mailbox` and `catch_all`. Boundstone does not knock on the mailbox to confirm the specific inbox exists, and it does not probe whether a domain accepts everything. Those are the checks vendors most often imply and least often actually run, so we list them as not done rather than dress them up. What you do get — real MX records on the domain, syntax, disposable and role-account flags — is enough to reject the large share of bad addresses that never had a chance of receiving mail. The `not_performed` list is not an apology. It is the reason a `valid` here means what it says.

## The short version

- **Real-time** — `POST /v1/verify/email`, one address, synchronous, 1 credit. Use it at the point of capture. Keep the `bs_live_` key server-side.
- **Batch** — `POST /v1/bulk/email`, raw CSV, `202` + `job_id`, results at `/v1/bulk/:id/results.csv`. Free cap 250 rows, paid up to 10,000; per-row credits refunded on error.
- **Same check either way** — identical `performed` and `not_performed`; a batch `valid` equals a live `valid`.
- **Use both** — batch to clean the list you have, real-time to keep the new ones clean.
- **Do less first** — catch broken syntax in your own code before spending a credit.
