Email verification in Flask

Flask leaves the structure to you, so the useful thing here is where each piece goes rather than how to call an API. Verification at signup belongs in a small helper with a hard timeout, and list work belongs on a worker.

One Flask-specific detail carries the webhook: request.get_data() returns the raw bytes and can be called more than once, which is exactly what signature verification needs.

requests.Session held on the app extension object, so it survives across requests and pools connections.

Verify one address

You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

Python
# app/verification.py
import requests
from flask import current_app

session = requests.Session()


def init_app(app):
    session.headers["Authorization"] = f"Bearer {app.config['ZAPBOUNCE_KEY']}"


def check(email: str) -> dict:
    """Return the response body, or an 'unknown' stand-in if we ran out of time.

    Two seconds is the budget. Someone is staring at a form.
    """
    try:
        response = session.post(
            "https://api.zapbounce.com/v1/verify",
            json={"email": email},
            timeout=(1.5, 2.0),
        )
        response.raise_for_status()
        return response.json()
    except requests.RequestException:
        current_app.logger.warning("zapbounce unreachable for %s", email)
        return {"result": "unknown", "disposable": False}


# app/routes.py
@bp.post("/signup")
def signup():
    email = request.form["email"]
    checked = check(email)

    # disposable is a flag beside the result, so it gets its own test.
    if checked["result"] == "invalid" or checked["disposable"]:
        flash("That address will not receive mail. Check it for a typo.")
        return redirect(url_for("main.signup"))

    # valid, catch_all and unknown all proceed. Only a definite no blocks.
    user = User.create(email=email, email_result=checked["result"])
    return redirect(url_for("main.welcome", user_id=user.id))

Verify a list

Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

Python
# app/jobs.py
from rq import Retry
from rq.decorators import job

from . import queue
from .verification import session


@job(queue, retry=Retry(max=3, interval=[30, 120, 600]))
def verify_list(list_id: int) -> str:
    emails = [row.email for row in Contact.query.filter_by(list_id=list_id, email_result=None)]

    response = session.post(
        "https://api.zapbounce.com/v1/batches",
        json={
            "name": f"list-{list_id}",
            "emails": emails,
            "webhook_url": current_app.config["ZAPBOUNCE_WEBHOOK_URL"],
        },
        headers={"Idempotency-Key": f"list-{list_id}"},
        timeout=(3, 60),
    )
    response.raise_for_status()
    return response.json()["batch_id"]


@job(queue)
def import_results(batch_id: str) -> None:
    cursor, seen = None, 0
    while True:
        params = {"limit": 1000, **({"cursor": cursor} if cursor else {})}
        page = session.get(
            f"https://api.zapbounce.com/v1/batches/{batch_id}/results",
            params=params, timeout=(3, 30),
        ).json()

        db.session.bulk_update_mappings(Contact, [
            {"email": r["email"], "email_result": r["result"]} for r in page["data"]
        ])
        db.session.commit()          # commit per page, so a crash loses one page
        seen += len(page["data"])

        if not page["has_more"]:
            break
        cursor = page["next_cursor"]

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

Python
# app/webhooks.py
import hashlib
import hmac
import time

from flask import Blueprint, abort, request

bp = Blueprint("webhooks", __name__)


@bp.post("/webhooks/zapbounce")
def zapbounce():
    raw = request.get_data()            # cache=True by default: safe to call again
    header = request.headers.get("ZapBounce-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)

    if abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(401, "stale signature")

    expected = hmac.new(
        current_app.config["ZAPBOUNCE_WEBHOOK_SECRET"].encode(),
        f"{parts['t']}.".encode() + raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(401, "bad signature")

    event = request.get_json()

    if not WebhookEvent.query.get(event["id"]):
        db.session.add(WebhookEvent(id=event["id"]))
        db.session.commit()
        import_results.delay(event["data"]["batch_id"])

    return "", 204                      # acknowledge now, import on the worker

Flask: common questions

Should signup block on catch-all?

No. The domain accepts every address by design, which tells you nothing about this person. Record the verdict and let them in.

RQ or Celery?

Either. RQ is here because the retry decorator is two lines and Flask projects often already have Redis.

How do I test the webhook locally?

Sign a body with the same HMAC in a test and POST it to the blueprint. No tunnel needed for the signature path, which is the part that breaks.

Run this Flask code today

100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.