Python developers validating an email address usually reach for one of two wrong answers. The first is a regex, which at best approximates RFC 5322 as practised and says nothing about whether the domain accepts mail. The second is a heavyweight library promising to confirm the mailbox exists — a claim that depends on SMTP probing, which requires sender-IP reputation to work at all and returns weak signals at catch-all providers. It is also permanently out of scope for nobounce, by design.
The useful answer is an HTTP call that returns a structured verdict plus, when the domain was mistyped, the correction. This is the whole integration in Python, standard library only.
Step 1: see the response shape with no account
POST /demo/check resolves a frozen fixture corpus — no key, no signup, rate-limited per client:
curl -X POST https://nobounce.dev/demo/check \
-H 'Content-Type: application/json' \
-d '{"email":"user@gmai.com"}'
{
"verdict": "undeliverable",
"reason": "typosquat_mx",
"suggestion": "user@gmail.com",
"confidence": 0.94,
"checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
"cached": false
}
It is fixture-only: every verdict and reason in the frozen taxonomy is reachable from it, which makes it ideal for writing your branching code, but it cannot validate arbitrary real addresses. The full fixture list and the frozen enums are at GET /v1/config.
Step 2: get a key into the environment
There is no free tier. Live DNS checks require a paid key, entry $1/mo for 1,000 checks. You either redeem an operator-minted access code or pay self-serve through the card or stablecoin rail — both are a single POST /v1/keys call. The key is shown once; put it in an environment variable and never in a URL or a query parameter:
export NOBOUNCE_KEY="nb_live_..."
Step 3: the check function
Two decisions matter more than the HTTP mechanics: a short timeout, and treating an unanswered call as no opinion rather than a rejection.
import json
import os
import urllib.error
import urllib.request
API_URL = "https://nobounce.dev/v1/check"
def check_email(email: str) -> dict | None:
"""Return the verdict object, or None when the API could not answer."""
request = urllib.request.Request(
API_URL,
data=json.dumps({"email": email}).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ['NOBOUNCE_KEY']}",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=2.0) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = json.load(error) # RFC 9457 problem+json
print(body.get("fix")) # plain-language recovery instruction
return None
except (urllib.error.URLError, TimeoutError, OSError):
return None # no opinion, not a rejection
Every non-2xx body carries a fix field that says what to do — a missing key, exhausted quota (429), a malformed request — so read it instead of retrying the identical call blindly.
Step 4: branch on all four verdicts
Four verdicts, and only one of them stops anything:
result = check_email("diego@gmai.com")
if result is None:
accept(mx_checked=False) # validator outage ≠ bad address
elif result["verdict"] == "undeliverable" and result["suggestion"]:
offer_correction(result["suggestion"], result["confidence"])
elif result["verdict"] == "undeliverable":
reject(result["reason"])
elif result["verdict"] == "unknown":
accept(mx_checked=result["checked"]["mx"]) # DNS did not complete — fail open
else:
accept(mx_checked=result["checked"]["mx"]) # deliverable, risky
What each branch means:
| verdict | meaning | your code |
|---|---|---|
deliverable |
syntax and MX both passed | accept |
risky |
deliverable but disposable or a role account (reason says which) |
your policy decision |
unknown |
the check did not complete; checked.mx is false |
accept and annotate, never reject |
undeliverable |
will bounce; reason is the cause |
reject, or offer suggestion |
Two branches deserve emphasis. A suggestion is not an error — undeliverable with a suggestion means you know which address the user meant to type, and offering it with one-click accept (plus a real "keep what I typed" escape hatch, since confidence is a probability) recovers a signup that a plain rejection loses. And unknown proceeds: an API timeout or a DNS failure upstream must never turn into a hard signup failure. The reasoning behind that rule, with the outage math, is in Fail Open: Email Validation Must Never Block a Signup.
A syntax_invalid on undeliverable needs no API at all — reject before spending a check. Everything else, from domain_not_found (NXDOMAIN) through typosquat_mx to reserved_domain (RFC 2606 names like example.com, which you should not treat as ordinary rejections), is a DNS-level fact the engine resolved for you.
Step 5: lists, and async paths
For one address per signup, the function above is complete. For a file of thousands, switch to POST /v1/check/batch — up to 1,000 addresses per call, results keyed by email_sha256 so responses are safe to log — and chunk your list accordingly: the chunking loop, quota pre-check and hash-join are covered in How to Batch-Validate a List of Email Addresses. If your stack is already async, httpx.AsyncClient with the same headers and a timeout=2.0 drops in without changing any of the verdict logic.
What happens to the address you sent
No plaintext address is stored anywhere in the service — anything persisted is SHA-256, domains in clear. If you later learn an address bounced, report it as a hash:
import hashlib
digest = hashlib.sha256("user@gmail.com".encode("utf-8")).hexdigest()
POST /v1/feedback accepts only that digest with the event; send a raw address and it returns a 400 rather than hashing it for you. POST /v1/hash computes the same digest server-side, in memory, if you need the canonical normalisation (trim, lowercase) applied first.
Limits worth knowing
Cache hits still count against the monthly quota — cached: true means the shared domain cache was warm, not that the check was skipped billing-wise. GET /v1/me reports checks_used and remaining quota, and the whole surface (including why the suggestion engine runs before the MX lookup) is in the OpenAPI spec and Suggest the Correction Instead of Rejecting the Signup.
The full ordered integration procedure, written for an agent to execute, is at /integrate.md.