---
title: "How to Validate an Email Address in Python"
description: "A working Python client for a validation API: one request, a short timeout, and branching on all four verdicts — including the typo suggestion that recovers the signup."
slug: "validate-email-in-python"
date: 2026-09-18
updated: 2026-09-18
last_tested: 2026-09-18
summary: "Skip the regex and the inbox-probing libraries: call /v1/check from the standard library, fail open on timeouts, and offer the suggested correction instead of rejecting."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "Python urllib.request — extensible library for opening URLs"
    url: "https://docs.python.org/3/library/urllib.request.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"
---

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:

```bash
curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
```

```json
{
  "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:

```bash
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.

```python
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:

```python
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](https://nobounce.dev/blog/fail-open-email-validation-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](https://nobounce.dev/blog/batch-validate-email-list/). 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:

```python
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](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

The full ordered integration procedure, written for an agent to execute, is at [/integrate.md](https://nobounce.dev/integrate.md).
