emailverifier.dev
Posts

How to Block Disposable Email Signups in FastAPI

| 5 min read | Usama Ejaz
FastAPI signup gate cover with an email passing through a server boundary before account creation.

A FastAPI signup route should call email verification on the server, before it creates the user or grants trial access. The browser sends the email and password to your application. Your application sends only the email to emailverifier.dev.

The finished endpoint in this guide has three observable outcomes:

  • 200 when the address can continue to account creation.
  • 202 when the result needs confirmation or another review step.
  • 422 when the address should be blocked, including known disposable addresses.

An unavailable verification request returns 503. It is kept separate from an email result because a timeout does not make an address invalid.

Build the complete FastAPI endpoint

Create a directory and virtual environment, then install FastAPI, HTTPX, and pytest:

mkdir fastapi-email-check
cd fastapi-email-check
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" httpx pytest

On Windows PowerShell, activate the environment with:

.venv\Scripts\Activate.ps1

FastAPI uses Pydantic models for JSON request bodies and returns validation errors before the route runs. The current FastAPI request-body documentation covers that boundary.

Create main.py:

import os
from typing import Literal

import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError

app = FastAPI()


class SignupInput(BaseModel):
    model_config = ConfigDict(extra="forbid")

    email: EmailStr
    password: str = Field(min_length=8, max_length=128)


class VerificationResult(BaseModel):
    email: str
    status: Literal[
        "deliverable",
        "risky",
        "undeliverable",
        "unknown",
    ]
    action: Literal["allow", "review", "block"]
    flagged: bool
    signals: list[str]
    suggestion: str | None


async def check_email(email: str) -> VerificationResult:
    api_key = os.getenv("EMAILVERIFIER_API_KEY")
    if not api_key:
        raise RuntimeError("EMAILVERIFIER_API_KEY is not set")

    try:
        async with httpx.AsyncClient(timeout=4.0) as client:
            response = await client.post(
                "https://emailverifier.dev/api/v1/verify",
                headers={"X-API-Key": api_key},
                json={"email": email},
            )
            response.raise_for_status()
            return VerificationResult.model_validate(
                response.json()
            )
    except (httpx.HTTPError, ValidationError, ValueError) as error:
        raise HTTPException(
            status_code=503,
            detail="Email verification is temporarily unavailable.",
        ) from error


@app.post("/signup")
async def signup(payload: SignupInput):
    result = await check_email(str(payload.email))

    if result.action == "block":
        message = (
            "Use a permanent email address."
            if "disposable_address" in result.signals
            else "Check the email address and try again."
        )
        raise HTTPException(
            status_code=422,
            detail={
                "message": message,
                "status": result.status,
                "signals": result.signals,
                "suggestion": result.suggestion,
            },
        )

    if result.action == "review":
        return JSONResponse(
            status_code=202,
            content={
                "decision": "review",
                "email": result.email,
                "status": result.status,
                "signals": result.signals,
                "suggestion": result.suggestion,
            },
        )

    # Put your existing user creation call here. Do not send the
    # password to the verification API.
    return {
        "decision": "allow",
        "email": result.email,
        "status": result.status,
    }

The route makes exactly one verification request. The password stays inside your application, and the API key stays inside the FastAPI process.

The four-second client timeout is deliberate. HTTPX supports explicit client timeouts and raises transport and status exceptions through its documented hierarchy. See the current async client, timeout, and exception references.

Prove the policy without using credits

The external call is isolated in check_email, so the route can be tested with a replacement result. Create test_main.py:

from fastapi.testclient import TestClient

import main
from main import VerificationResult

client = TestClient(main.app)


def result(action: str, status: str, signals: list[str]):
    return VerificationResult(
        email="person@example.com",
        status=status,
        action=action,
        flagged=action == "block",
        signals=signals,
        suggestion=None,
    )


def test_disposable_email_is_blocked(monkeypatch):
    async def blocked(_email: str):
        return result(
            "block",
            "risky",
            ["disposable_address"],
        )

    monkeypatch.setattr(main, "check_email", blocked)
    response = client.post(
        "/signup",
        json={
            "email": "person@example.com",
            "password": "correct-horse",
        },
    )

    assert response.status_code == 422
    assert response.json()["detail"]["message"] == (
        "Use a permanent email address."
    )


def test_unknown_email_goes_to_review(monkeypatch):
    async def unknown(_email: str):
        return result(
            "review",
            "unknown",
            ["verification_inconclusive"],
        )

    monkeypatch.setattr(main, "check_email", unknown)
    response = client.post(
        "/signup",
        json={
            "email": "person@example.com",
            "password": "correct-horse",
        },
    )

    assert response.status_code == 202
    assert response.json()["decision"] == "review"


def test_invalid_input_never_reaches_verification(monkeypatch):
    async def should_not_run(_email: str):
        raise AssertionError("verification should not run")

    monkeypatch.setattr(main, "check_email", should_not_run)
    response = client.post(
        "/signup",
        json={"email": "not-an-email", "password": "short"},
    )

    assert response.status_code == 422

Run the tests:

pytest -q

FastAPI's testing guide documents the synchronous TestClient pattern used here.

Make one live request

Create an emailverifier.dev account and project, then copy its API key. Set it in the same terminal that will run FastAPI:

export EMAILVERIFIER_API_KEY="your-project-key"
fastapi dev main.py

For Windows PowerShell:

$env:EMAILVERIFIER_API_KEY = "your-project-key"
fastapi dev main.py

Call the route from another terminal:

curl http://127.0.0.1:8000/signup \
  -H "Content-Type: application/json" \
  -d '{
    "email": "person@example.com",
    "password": "correct-horse"
  }'

Keep all four verification statuses

The action field controls this route, while status preserves the evidence returned by the API:

  • deliverable can produce allow or review. Role-based, catch-all, and typo signals may still need review.
  • risky includes disposable addresses and some inconclusive typo cases. Follow the returned action instead of blocking every risky result blindly.
  • undeliverable represents a confirmed syntax, routing, or recipient failure and returns block.
  • unknown means the mailbox evidence was inconclusive. The sample returns 202 so confirmation or another restricted flow can continue.

The exact fields are documented in the emailverifier.dev REST API reference. For retry and logging decisions beyond this endpoint, use the production result-handling guide.

Connect your actual user insert at the marked line only. The implementation is complete when malformed input never calls the external service, disposable addresses return 422, unknown results return 202, and the project key is absent from browser code and responses.

Continue reading