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.
# 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.
# 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.
# 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 workerFlask: 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.