Email verification in Django
Django's validator hook is the natural place for a signup check, with one rule: it must fail open. A validator that raises ValidationError when the API times out rejects a real customer because of a slow socket, and you will never see the lost registration in your logs.
For anything list-shaped, a Celery task submits a batch and a webhook view takes the callback. No polling loop, no management command running under nohup.
requests with a module-level Session, wired into a django.core.validators callable and a Celery task.
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.
# accounts/validators.py
import logging
import requests
from django.conf import settings
from django.core.exceptions import ValidationError
logger = logging.getLogger(__name__)
_session = requests.Session()
_session.headers["Authorization"] = f"Bearer {settings.ZAPBOUNCE_KEY}"
# catch_all and unknown are not rejections. Only a definite no blocks a signup,
# plus the disposable flag, which is a boolean that sits beside the result.
def deliverable_email(value: str) -> None:
"""Field validator. Fails OPEN: a slow verifier must not cost a signup."""
try:
response = _session.post(
"https://api.zapbounce.com/v1/verify",
json={"email": value},
timeout=(2, 3),
)
response.raise_for_status()
except requests.RequestException:
logger.warning("zapbounce unreachable, allowing %s", value)
return
result = response.json()
if result["result"] == "invalid" or result["disposable"]:
raise ValidationError("That address will not receive mail. Check it for a typo.")
# accounts/forms.py
class SignupForm(forms.Form):
email = forms.EmailField(validators=[deliverable_email])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.
# contacts/tasks.py
from celery import shared_task
from django.db import transaction
from .models import Contact, ContactList
@shared_task(bind=True, max_retries=3, retry_backoff=True, retry_jitter=True)
def verify_list(self, list_id: int) -> str:
emails = list(
Contact.objects.filter(list_id=list_id, email_result="")
.values_list("email", flat=True)
.iterator(chunk_size=5000) # do not materialise a million rows
)
response = _session.post(
"https://api.zapbounce.com/v1/batches",
json={
"name": f"list-{list_id}",
"emails": emails,
"webhook_url": settings.ZAPBOUNCE_WEBHOOK_URL,
},
headers={"Idempotency-Key": f"list-{list_id}-{self.request.id}"},
timeout=(3, 60),
)
response.raise_for_status()
batch_id = response.json()["batch_id"]
with transaction.atomic():
ContactList.objects.filter(pk=list_id).update(batch_id=batch_id)
return batch_id
@shared_task
def import_results(batch_id: str) -> None:
"""Called from the webhook view. Bulk-updates rather than saving row by row."""
updates = []
for row in paginate_results(batch_id):
updates.append(Contact(email=row["email"], email_result=row["result"]))
Contact.objects.bulk_update(updates, ["email_result"], batch_size=1000)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.
# contacts/views.py
import hashlib
import hmac
import time
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt # no session, no token: Django would 403 this before it runs
@require_POST
def zapbounce_webhook(request):
raw = request.body # bytes, before any parsing
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:
return HttpResponseForbidden("stale signature")
expected = hmac.new(
settings.ZAPBOUNCE_WEBHOOK_SECRET.encode(),
f"{parts['t']}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return HttpResponseForbidden("bad signature")
event = json.loads(raw)
# Deliveries repeat. get_or_create makes a replay a no-op.
_, created = WebhookEvent.objects.get_or_create(id=event["id"])
if created:
import_results.delay(event["data"]["batch_id"])
return HttpResponse(status=204)Django: common questions
Should the validator block on unknown?
Never. Unknown means a mail server refused to answer, which says nothing about the person filling in your form.
Do I need Celery?
Something off the request cycle, yes. Celery, RQ or Django-Q all work; the batch endpoint plus a webhook means the task is short whichever you pick.
How do I test the unknown branch?
Point a sandbox key at unknown@sandbox.zapbounce.com in your test settings. It costs nothing and hits the branch that never fires locally.
Run this Django code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.