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
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"]}'
{
"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:
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:
curl https://nobounce.dev/v1/me -H "Authorization: Bearer $NOBOUNCE_KEY"
Then chunk:
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.
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 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.
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.