Home / Integrations / Django
Validate Emails in a Django Form clean_email Method
Published 2026-10-18
This guide adds a ContactLint check to a Django form through its clean_email method. Errors show up as normal field errors in your template, typos get a suggestion the person can accept or keep, and the form still validates if ContactLint does not answer within 3 seconds.
Where the check goes in Django
Browser checks are easy to skip, so the check that counts runs on the server, right before you save the address. In Django the natural place is the form's clean_email method. It runs during form.is_valid(), after Django's own EmailField syntax check passes. The same method works on a plain Form, a ModelForm, or a subclass of UserCreationForm that adds an email field.
Keep the secret key in the CONTACTLINT_KEY environment variable, never in templates or static files. Get a free key at /signup.
Setup: the API call, the form and the view
Install requests with pip install requests. Then add contactlint.py to your app:
import logging
import os
import requests
log = logging.getLogger(__name__)
URL = 'https://contactlint.com/v1/email/validate'
def lookup(email):
"""Return the ContactLint result dict, or None if ContactLint did not answer."""
try:
r = requests.post(
URL,
headers={'Authorization': 'Bearer ' + os.environ['CONTACTLINT_KEY']},
json={'email': email},
timeout=3,
)
if r.status_code != 200:
log.warning('ContactLint returned %s, failing open', r.status_code)
return None
return r.json()['result']
except (requests.RequestException, ValueError, KeyError) as exc:
log.warning('ContactLint check skipped: %r', exc)
return NoneThe form, in forms.py:
from django import forms
from .contactlint import lookup
class SignupForm(forms.Form):
email = forms.EmailField()
confirmed_email = forms.CharField(required=False, widget=forms.HiddenInput)
def clean_email(self):
email = self.cleaned_data['email']
result = lookup(email)
if result is None:
return email # fail open
if result['verdict'] == 'invalid':
raise forms.ValidationError('That email address cannot receive mail.')
# Check did_you_mean before disposable: gmial.com is a typo and on the disposable list.
suggestion = result.get('did_you_mean')
if suggestion and self.data.get('confirmed_email') != email:
self.data = self.data.copy()
self.data['confirmed_email'] = email
raise forms.ValidationError(
f'Did you mean {suggestion}? Fix the address, or press Sign up again to keep it.'
)
if result.get('disposable'):
raise forms.ValidationError('Please use a permanent email address.')
return emailThe view and template:
from django.shortcuts import redirect, render
from .forms import SignupForm
def signup(request):
form = SignupForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
# Create the user with form.cleaned_data['email'] here.
return redirect('welcome')
return render(request, 'signup.html', {'form': form})<form method="post">
{% csrf_token %}
{{ form }}
<button type="submit">Sign up</button>
</form>
Handling each verdict and did_you_mean
- invalid: the address is malformed or its domain has no MX or A record. A field error asks for another address.
- risky, possible_typo: the field error shows the suggestion. The form also writes the typed address into the hidden
confirmed_emailfield. If the person submits again without changing it, the suggestion is skipped. If they type the fix, it is checked as a new address. - risky, disposable: rejected here. Remove that branch if temporary addresses are fine for you.
- risky, role_address, valid, unknown: accepted.
A kept typo still goes through the disposable check, so keeping jane@gmial.com ends with the permanent address error. Django escapes field errors in templates, so the suggestion is safe to display. For a clickable hint before submit, add the signup form widget with a cl_pub_ publishable key. The site builder guide shows the data-selector option for fields you cannot add attributes to.
Testing it
Open python manage.py shell with CONTACTLINT_KEY set, and replace yourapp with your app name:
>>> from yourapp.forms import SignupForm
>>> f = SignupForm({'email': 'jane@gmial.com'})
>>> f.is_valid(), f.errors['email']
>>> f = SignupForm({'email': 'jane@gmial.com', 'confirmed_email': 'jane@gmial.com'})
>>> f.is_valid(), f.errors['email']
>>> f = SignupForm({'email': 'x@mailinator.com'})
>>> f.is_valid(), f.errors['email']- The first form fails with the "Did you mean jane@gmail.com?" error.
- The second fails with the permanent address error, because the typo domain is also disposable.
- The third fails with the permanent address error.
- Set a wrong key and repeat with any address. ContactLint returns 401, a warning is logged, and the form is valid.
Each check uses one of your 100 free checks per month.
Limits: no mailbox check, and fail open
- ContactLint does not connect to mail servers, so it cannot confirm that a mailbox exists or detect catch-all domains. It checks syntax, MX and A records, known disposable domains, role addresses and typos of major providers.
lookupreturnsNoneon timeouts, network errors and non-200 responses, including 401, 402 (no free checks or credits left) and 429 (more than 60 requests per minute on the key). The form then accepts the address.- The request is synchronous with a 3 second ceiling. An
unknownresult from a failed DNS lookup is not billed.
Questions
How do I keep tests from calling the API?
Patch the lookup function where the form imports it, for example unittest.mock.patch('yourapp.forms.lookup', return_value=None) to test the fail open path. You can also return a dict such as {'verdict': 'risky', 'did_you_mean': 'jane@gmail.com', 'disposable': True} to test the suggestion branch.
Does this work with UserCreationForm or a ModelForm?
Yes. Subclass the form, add an email field if it does not have one (the default UserCreationForm does not), add the confirmed_email hidden field, and add the same clean_email method. Django calls clean_email during is_valid() on any form that has an email field.
Does ContactLint store the addresses my form checks?
No. Addresses are processed only to return a result and are not stored in the ContactLint database. ContactLint records which key made the check, the check type, whether it used a free check or a credit, and the time.
Other integrations
- Next.js
- Express
- FastAPI
- Laravel
- Ruby on Rails
- Supabase
- Firebase Authentication
- WordPress
- Webflow
- Zapier
- Make
- n8n
Get a free API key. 100 checks per month, no card required. Or try a live check first.