---
title: "How to Batch-Validate a List of Email Addresses"
description: "Chunk to 1,000, correlate results by SHA-256, honour remaining quota, and treat suggestions as recoveries when cleaning an address list."
slug: "batch-validate-email-list"
date: 2026-08-24
updated: 2026-08-24
last_tested: 2026-08-24
summary: "POST /v1/check/batch returns one verdict per address keyed by email_sha256; chunk oversized lists, check remaining quota first, and act on suggestions instead of only counting rejects."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "RFC 6234 — US Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF)"
    url: "https://www.rfc-editor.org/rfc/rfc6234.html"
  - title: "Python hashlib — secure hashes and message digests"
    url: "https://docs.python.org/3/library/hashlib.html"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

Signup validation is one address at a time. List cleanup is not. Imports, CRM exports, newsletter migrations and dormant-user audits arrive as files of thousands of rows, and calling `/v1/check` once per row is the slowest correct answer. The batch endpoint exists for that shape: one HTTP call, up to 1,000 addresses, results keyed by hash so the response is safe to log.

This is a working loop against `POST /v1/check/batch`. Live checks need a paid key — hobby starts at $1/mo for 1,000 checks — and the key goes in the `Authorization` header, never in a URL.

## What the endpoint returns

```bash
curl -X POST https://nobounce.dev/v1/check/batch \
  -H "Authorization: Bearer $NOBOUNCE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"emails":["diego@gmail.com","user@gmial.com","admin@hotmail.com"]}'
```

```json
{
  "count": 3,
  "results": [
    {
      "email_sha256": "…",
      "verdict": "deliverable",
      "reason": "ok",
      "suggestion": null,
      "confidence": 0.99,
      "checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
      "cached": true
    }
  ]
}
```

Each result carries the same frozen verdict object as the single-check endpoint, plus `email_sha256`. The plaintext address is not echoed back. That is deliberate: a batch response can go into a log, a ticket, or an audit table without becoming a new store of personal data. nobounce does not store plaintext addresses either — anything persisted is SHA-256, domains in clear.

## Correlate by hash, not by array index

Relying on `results[i]` matching `emails[i]` works until a future change, a filtered client, or a merge with another list breaks the assumption. Hash the normalised address yourself and join on that.

Normalisation is lowercase plus trim — the same transform the API applies before hashing:

```python
import hashlib, os, httpx

def email_sha256(email: str) -> str:
    normalised = email.strip().lower()
    return hashlib.sha256(normalised.encode("utf-8")).hexdigest()

def check_batch(emails: list[str]) -> dict[str, dict]:
    response = httpx.post(
        "https://nobounce.dev/v1/check/batch",
        headers={"Authorization": f"Bearer {os.environ['NOBOUNCE_KEY']}"},
        json={"emails": emails},
        timeout=30.0,
    )
    response.raise_for_status()
    body = response.json()
    return {row["email_sha256"]: row for row in body["results"]}

originals = ["diego@gmail.com", "User@gmial.com", "admin@hotmail.com"]
by_hash = check_batch(originals)

for address in originals:
    result = by_hash[email_sha256(address)]
    print(address, result["verdict"], result["reason"], result["suggestion"])
```

If you already have hashes and need to confirm the algorithm without sending an address into your own logs, `POST /v1/hash` computes the same digest in memory and does not persist it.

## Chunk at 1,000 and pre-check quota

The hard cap is 1,000 addresses per call. Over that, the API returns `413` with `error: batch_too_large`, plus `limit` and `received` in the body so you can split without guessing. Every error is RFC 9457 problem+json with a plain-language `fix` field — read it before retrying.

Quota is checked up front for the whole batch. If the remaining monthly allowance is smaller than `emails.length`, you get `429 quota_exceeded` with `remaining` and `requested`. A partial run that burns the last 40 checks of a 200-row batch and then fails mid-list is worse than a refused call, so inspect remaining capacity first:

```bash
curl https://nobounce.dev/v1/me -H "Authorization: Bearer $NOBOUNCE_KEY"
```

Then chunk:

```python
BATCH_LIMIT = 1000

def chunks(addresses: list[str], size: int = BATCH_LIMIT):
    for i in range(0, len(addresses), size):
        yield addresses[i : i + size]

for group in chunks(all_addresses):
    by_hash = check_batch(group)
    # process…
```

A cache hit still counts against the monthly quota. `cached: true` means the DNS lookup was skipped because the shared domain cache was warm — not that the check was free. `GET /v1/me` reports `checks_used` and `cache_hits` separately, which is how you tell whether a large run was mostly warm.

## Act on suggestions, not only on rejects

The failure mode of list cleaning is a spreadsheet with a "bad" column and nothing else. That discards the recoverable rows. The interesting output is the set of addresses where `verdict` is `undeliverable` and `suggestion` is present — those are typos with a named correction, the same product behaviour described in [Suggest the Correction Instead of Rejecting the Signup](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

A practical partition:

| verdict | suggestion | list action |
|---|---|---|
| `deliverable` | — | keep |
| `undeliverable` | present | queue for review with the suggested address |
| `undeliverable` | `null` | remove, or quarantine by `reason` |
| `risky` | — | keep, flag if your policy blocks `disposable` / `role_account` |
| `unknown` | — | keep for re-check; DNS did not complete |

`unknown` with `checked.mx: false` is the fail-open signal, not a soft reject. For an offline list that means "re-queue", not "delete". Treating a degraded check as bad data permanently removes real subscribers who happened to be in the batch during a resolver incident — the same trap [Fail Open: Email Validation Must Never Block a Signup](https://nobounce.dev/blog/fail-open-email-validation-signup/) warns about on the interactive path.

Do not auto-rewrite the list to the suggestion without a human or an explicit product rule. Confidence is a probability. Export a three-column review file — original, suggestion, confidence — and apply accepted corrections in a second pass.

## What batch is for, and what it is not

Use batch for offline or asynchronous work: imports, re-validation of rows stored with `email_mx_checked = 0`, newsletter hygiene, agent-driven cleanup jobs. Keep the interactive signup path on `/v1/check` with a short timeout, as in the [agent validation walkthrough](https://nobounce.dev/blog/validate-emails-from-an-agent/).

Do not point production signup traffic at `/demo/check`. That endpoint resolves a frozen fixture corpus with no key; it exists so you can inspect every verdict and reason shape before paying. It cannot validate arbitrary live addresses.

Batch also does not invent mailbox-level certainty. There is no SMTP RCPT TO probing and no proprietary bounce database — both are permanently out of scope. What you get is syntax, reserved-name handling, typo correction, MX-over-DoH, typosquat MX clustering, disposable detection and role-account flagging. That is enough to remove the addresses that will definitely bounce and to recover the ones that were only misspelled.

Wire the full signup path, including how to obtain a key and map verdicts, from [/integrate.md](https://nobounce.dev/integrate.md).
