How to verify email addresses in Python

In Python you verify an email by making one POST request to a verification API from your server and reading the status it returns. Put plainly: your app hands the address to the verification service, asks "is this a real, reachable mailbox?", and accepts, flags, or rejects it based on the answer. Below is complete code for both worlds — synchronous requests with Django, async httpx with FastAPI, plus a simple batch loop for cleaning a list.

Written & reviewed by the VerifyAnyEmail team · Last reviewed July 2026

Email verification checks whether an address can actually receive mail before you trust it — catching typos like gmial.com, disposable inboxes, and mailboxes that no longer exist. VerifyAnyEmail runs a live SMTP probe without ever sending a message and returns one status: deliverable, undeliverable, risky, or unknown. In Python you make a single HTTP call and branch on that value.

Why this runs server-side

The non-negotiable rule: never expose your API key in client-side code. A key baked into a browser script, a desktop app, or a public repo can be lifted by anyone and used to drain your credits. Python code running in your Django or FastAPI backend is server-side, so keep the key in an environment variable (os.environ) — never hard-coded, never returned to the client, never committed. The full checklist is in how to protect your API keys.

The request is always POST https://api.verifyany.email/v1/verify with an Authorization: Bearer vae_... header and a JSON body of { "email": "name@example.com" }. The response looks like { "ok": true, "result": { "status": "deliverable", "score": 98, ... } }. Full schema in the API docs.

Synchronous: requests + Django

The requests library is the simplest way in. This client never raises — every failure collapses to { "status": "unknown" }, so a slow or down API can't crash your signup flow.

# emails/verifier.py — synchronous client using requests. Server-side only.
import os
import requests

API_URL = "https://api.verifyany.email/v1/verify"

def verify_email(email: str) -> dict:
    """Returns the result dict. Never raises: every failure -> {"status": "unknown"}."""
    try:
        resp = requests.post(
            API_URL,
            headers={"Authorization": f"Bearer {os.environ['VAE_API_KEY']}"},
            json={"email": email},
            timeout=10,  # seconds — never let a request hang
        )
    except requests.RequestException:
        return {"status": "unknown"}  # timeout / connection error -> fail open

    if resp.status_code == 429 or resp.status_code >= 500:
        return {"status": "unknown"}

    data = resp.json()
    if not data.get("ok"):
        return {"status": "unknown"}
    return data["result"]  # {"status": ..., "subStatus": ..., "score": ..., ...}

Drop that into a Django view. Verification runs after Django's own form validation, and only a confirmed undeliverable blocks the request.

# emails/views.py — verify at signup in a Django view.
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .verifier import verify_email

@require_POST
def signup(request):
    email = request.POST.get("email", "")
    result = verify_email(email)

    # Only block a DEFINITE negative. risky / unknown proceed (fail open).
    if result.get("status") == "undeliverable":
        return JsonResponse(
            {"error": "That email address does not exist.",
             "suggestion": result.get("suggestion")},  # "did you mean" fix
            status=422,
        )

    is_risky = result.get("status") == "risky"  # catch-all / role / disposable
    # ... create the user, store is_risky for later review ...
    return JsonResponse({"ok": True}, status=201)

Async: httpx + FastAPI

If you're on an async stack, don't block the event loop with a synchronous call. Use httpx's async client. FastAPI's Pydantic EmailStr handles the cheap RFC syntax check for free, so the network probe only runs on well-formed input.

# main.py — async client with httpx, wired into FastAPI.
import os
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

app = FastAPI()

class SignupIn(BaseModel):
    email: EmailStr  # cheap RFC syntax check before we ever hit the network

async def verify_email(email: str) -> dict:
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.post(
                "https://api.verifyany.email/v1/verify",
                headers={"Authorization": f"Bearer {os.environ['VAE_API_KEY']}"},
                json={"email": email},
            )
    except httpx.RequestError:
        return {"status": "unknown"}  # fail open on timeout / network error

    if resp.status_code == 429 or resp.status_code >= 500:
        return {"status": "unknown"}
    body = resp.json()
    return body["result"] if body.get("ok") else {"status": "unknown"}

@app.post("/signup")
async def signup(payload: SignupIn):
    result = await verify_email(payload.email)
    if result.get("status") == "undeliverable":
        raise HTTPException(status_code=422, detail="Enter a valid email address.")
    return {"ok": True, "risk": result.get("subStatus")}

How to branch on the status

Four statuses, four actions — the policy matters more than the code:

  • deliverable — real, reachable mailbox. Accept it.
  • undeliverable — mailbox doesn't exist or the domain can't receive mail. Block it, and surface result["suggestion"] if present (the "did you mean" typo fix).
  • risky — catch-all domain, role address (info@, support@), or disposable inbox. Accept but store subStatus to review or segment later.
  • unknown — greylisting, timeout, or a temporary SMTP failure. Fail open: let the signup through and re-verify later. Blocking on unknown rejects real users over a transient hiccup.

This fail-open policy is what keeps verification from hurting conversion — details in verify emails at signup without hurting conversion.

Cleaning a list: a simple batch loop

To verify a CSV you already have, loop with retries and gentle pacing. This is fine for a few thousand rows.

# batch_clean.py — clean a CSV list, retrying on rate limits.
# For big lists, prefer the async /v1/verify/batch endpoint (see the batch guide).
import csv
import os
import time
import requests

API_URL = "https://api.verifyany.email/v1/verify"
HEADERS = {"Authorization": f"Bearer {os.environ['VAE_API_KEY']}"}

def verify_one(email: str) -> dict:
    for attempt in range(3):
        resp = requests.post(API_URL, headers=HEADERS,
                             json={"email": email}, timeout=15)
        if resp.status_code == 429:
            time.sleep(2 ** attempt)  # exponential backoff: 1s, 2s, 4s
            continue
        resp.raise_for_status()
        return resp.json()["result"]
    return {"status": "unknown"}

with open("contacts.csv") as f, open("clean.csv", "w", newline="") as out:
    writer = csv.writer(out)
    writer.writerow(["email", "status", "score"])
    for row in csv.reader(f):
        email = row[0].strip()
        r = verify_one(email)
        writer.writerow([email, r["status"], r.get("score", "")])
        time.sleep(0.1)  # gentle pacing between requests

For anything large, don't hammer the single-verify endpoint — submit the whole list to the asynchronous /v1/verify/batch endpoint and receive a signed webhook when it finishes. The patterns (idempotency, polling vs webhooks, pagination) are in processing large verification batches. If you consume the completion webhook, confirm your signature check with the Webhook Signature Tester.

Testing without spending credits

Every address at @test.verifyany.email returns a deterministic result and costs zero credits — no real mailbox is probed. Use them in pytest and local development:

  • deliverable@test.verifyany.emailstatus: deliverable
  • undeliverable@test.verifyany.emailstatus: undeliverable
  • risky@test.verifyany.emailstatus: risky
  • unknown@test.verifyany.emailstatus: unknown

Point verify_email() at each address to cover every accept/flag/block branch without touching your balance. You can also try any address by hand in the Email Verifier.

Errors, timeouts and retries

  • Always pass a timeout. Both clients above set one; requests will otherwise wait forever. A verification call must never hang a web request.
  • Fail open on 429 and 5xx. Return unknown and proceed. In the batch loop, back off on 429 (the example uses exponential 2 ** attempt seconds).
  • Don't retry 400 or 401. A 400 is malformed input and 401 is a bad key — bugs to fix, not transient errors. (402 means you're out of credits.)
  • Reuse a session/client. For loops, a single requests.Session() or one httpx.AsyncClient reuses connections and is noticeably faster than a fresh client per call.

Quick FAQ

Does this send an email to the person? No. The SMTP probe stops before delivery, so nothing reaches the recipient's inbox.

Sync or async? Use requests in Django and scripts; use httpx in FastAPI or any asyncio app so you don't block the event loop.

Should I verify inline or in a task queue? A single signup is fine inline with a 10-second timeout. For imports and list cleaning, use Celery/RQ or the batch API.

Working in another language? See the companion guides for PHP and Laravel and Node.js and Express, plus the full API documentation.

Sources

The examples use requests (sync) and httpx (async), wired into Django views and FastAPI.

Check your domain in seconds

Run the free diagnostics referenced in this guide — no sign-up needed.