Home / Integrations / FastAPI
Validate Emails in FastAPI with a ContactLint Dependency
Published 2026-10-18
This guide adds a FastAPI dependency that checks the signup email with ContactLint before your endpoint runs. Bad addresses get a 422 with a clear message or a typo suggestion, and the request goes through untouched if ContactLint does not answer within 3 seconds.
Where the check goes in FastAPI
Browser checks are easy to skip, so the check that counts runs on the server, right before you save the address. In FastAPI the cleanest place is a dependency. It reads the request body, calls ContactLint, and either raises an HTTPException or hands the body to your endpoint. You can reuse it on any route that takes a new address.
A Pydantic validator is the wrong place for this. Validators run whenever the model is built and are not meant for network calls. Keep the secret key in the CONTACTLINT_KEY environment variable on the server. Get a free key at /signup.
Setup: the dependency and the endpoint
Install the packages with pip install fastapi uvicorn httpx, then save this as main.py:
import os
from typing import Annotated, Optional
import httpx
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
CONTACTLINT_URL = 'https://contactlint.com/v1/email/validate'
class SignupIn(BaseModel):
email: str
confirmed_email: Optional[str] = None
async def contactlint_result(email: str) -> Optional[dict]:
"""Return the ContactLint result, or None if ContactLint did not answer."""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
r = await client.post(
CONTACTLINT_URL,
headers={'Authorization': 'Bearer ' + os.environ['CONTACTLINT_KEY']},
json={'email': email},
)
if r.status_code != 200:
return None # 401, 402, 429 or other error: fail open
return r.json()['result']
except (httpx.HTTPError, ValueError, KeyError):
return None # timeout, network error or missing key: fail open
def reject(**detail):
raise HTTPException(status_code=422, detail={'field': 'email', **detail})
async def checked_signup(body: SignupIn) -> SignupIn:
email = body.email.strip()
if not email:
raise HTTPException(status_code=400, detail={'field': 'email', 'message': 'Email is required.'})
body.email = email
result = await contactlint_result(email)
if result is None:
return body
if result['verdict'] == 'invalid':
reject(message='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 body.confirmed_email != email:
reject(suggestion=suggestion, message=f'Did you mean {suggestion}?')
if result.get('disposable'):
reject(message='Please use a permanent email address.')
return body
app = FastAPI()
@app.post('/signup', status_code=201)
async def signup(body: Annotated[SignupIn, Depends(checked_signup)]):
# Save the user here with body.email.
return {'ok': True}Run it with CONTACTLINT_KEY=your_key uvicorn main:app --reload. This opens one HTTP client per check, which is simple and fine for signup traffic. For heavier use, share one httpx.AsyncClient across requests. If you prefer an endpoint check over a dependency, call await checked_signup(body) at the top of the endpoint instead.
Handling each verdict and did_you_mean
- invalid (malformed, or no MX or A record): 422 with a
message. - risky, possible_typo: 422 with
suggestionandmessage, unlessconfirmed_emailequals the address. - risky, disposable: 422 with a
message. Remove that branch if you accept temporary addresses. - risky, role_address, valid, unknown: the endpoint runs.
FastAPI's own body validation errors also use 422, with a list in detail. Your frontend can tell them apart because this dependency always sends an object with field. When detail.suggestion is present, show "Did you mean ...?" with a button that puts the suggestion in the field, and a second button that resends the request with confirmed_email set to the typed address.
A kept typo still goes through the disposable check, so keeping jane@gmial.com returns the permanent address message. For a hint before submit, add the signup form widget with a cl_pub_ publishable key.
Testing it
- Typo:
curl -i -X POST http://localhost:8000/signup -H "content-type: application/json" -d '{"email":"jane@gmial.com"}'Expect 422 with"suggestion":"jane@gmail.com"insidedetail. - Kept typo: add
"confirmed_email":"jane@gmial.com". Expect 422 with the permanent address message, because the domain is also disposable. - Disposable:
x@mailinator.com. Expect the permanent address message. - An address on your own domain: expect 201.
- Fail open: restart with a wrong key. ContactLint answers 401 and the endpoint returns 201.
Each check uses one of your 100 free checks per month. The interactive docs at /docs on your local server also work for these tests.
Limits: no mailbox check, and fail open
- ContactLint never opens an SMTP connection, 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.
- The dependency fails open on 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).
- An
unknownresult caused by a failed DNS lookup is not billed.
For the full field list, see the API docs. To check addresses you already have, use the batch CSV script.
Questions
Why use a dependency instead of a Pydantic validator?
The check is a network call with a timeout and a fail open path. A dependency is async, runs once per request, and can raise HTTPException with a clear detail. Validators run every time the model is built, including in tests and internal code, and are not meant for network calls.
What happens if my app sends more than 60 checks a minute?
ContactLint returns 429 for that key. The dependency treats it like any other error and lets the signup through unchecked. If you expect bursts above that, keep in mind those signups will not be checked.
Can the dependency tell me the mailbox exists?
No. ContactLint does not connect to mail servers. A valid verdict means the address is well formed and its domain has mail records. If you need mailbox-level checks, use a mailbox verifier alongside it.
Other integrations
- Next.js
- Express
- Django
- 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.