An async dependency that vets the address before your route runs. Bad addresses raise a 422.
The code
1 Keep the key on the server
MAILVET_KEY=mv_live_your_keySet it wherever your server reads its environment. Never put it in client code.
2 Install httpx
pip install fastapi httpx3 The dependency and the route
main.py
import logging
import os
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
log = logging.getLogger(__name__)
app = FastAPI()
class Signup(BaseModel):
email: str
async def check_email(email: str) -> dict | None:
# Returns MailVet's answer, or None if there is no answer.
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(
"https://mailvet.dev/v1/check",
params={"email": email},
headers={"Authorization": f"Bearer {os.environ['MAILVET_KEY']}"},
)
r.raise_for_status()
return r.json()
except (httpx.HTTPError, KeyError, ValueError) as err:
log.warning("mailvet: no answer: %s", err)
return None
async def vetted_email(body: Signup) -> str:
email = body.email.strip()
check = await check_email(email)
if check is None:
return email # a signup shouldn't die because of us
if check["did_you_mean"]:
local = email.rpartition("@")[0]
raise HTTPException(422, f"Did you mean {local}@{check['did_you_mean']}?")
if check["verdict"] == "invalid":
raise HTTPException(422, "That address can't receive mail.")
if check["verdict"] == "risky" and check["disposable"]:
raise HTTPException(422, "Please use a permanent address, not a throwaway one.")
return email
@app.post("/signup", status_code=201)
async def signup(email: Annotated[str, Depends(vetted_email)]):
# Create the user here.
return {"email": email}What to do with the answer
did_you_mean, invalid and disposable raise HTTPException, so the route never runs and the client gets the message in detail. Role addresses and a risky from a DNS failure reach the route.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
httpx.HTTPError covers timeouts, network errors and the non-2xx answers from raise_for_status. ValueError covers bad JSON. All of them log and return None, so the dependency returns the address and the route runs.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
timeout=5applies to each network step (connect, read and so on). It isn't a limit on the whole call.httpx.AsyncClientper call is the short version. Under real load, share one client through FastAPI's lifespan.
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.