DEV Community

Srinivasa Rao
Srinivasa Rao

Posted on

Building a Multi-Tenant Email System with FastAPI and Resend: What the Docs Don't Tell You

When I was tasked with building an email feature that would let multiple clients send emails from their own domains — all through a single backend — I assumed Resend's Audiences and Broadcasts API would handle everything. The docs make it look clean.

It isn't.

After working through the actual implementation, I realized that Resend's Segments have no API filtering support, Audiences and Contacts are completely separate from Domains with no native relationship, and Broadcasts work but don't give you the per-customer control you need in a multi-tenant setup.

The pattern that actually works is simpler: use Resend only for domain verification and email delivery, and own the contact and segment layer yourself in your database.

This article walks through exactly how I built that in Python with FastAPI — including the testing approach that lets you verify everything works without owning a real domain or paying for API calls.


What We're Building

A backend service where:

  • Multiple customers (your clients) each send emails from their own verified domain (acme.com, betacorp.com)
  • All sending goes through a single Resend account and one API key
  • Contacts, segments, and campaign history are stored in your own database — not in Resend
  • Delivery events (sent, bounced, opened, clicked) come back via Resend webhooks

Here's the architecture:

Your Customers
    │
    ▼
FastAPI Backend
    ├── Customer A → domain: acme.com     ─┐
    ├── Customer B → domain: betacorp.com  ├──▶ Resend (sending engine only)
    └── Customer C → domain: delta.io    ─┘
    │
    └── Your Database (SQLite locally, PostgreSQL in production)
            ├── customers  (domain mapping + verification status)
            ├── contacts   (owned by customer, with tags)
            ├── segments   (named groups of contacts)
            └── campaigns  (history of every send)
Enter fullscreen mode Exit fullscreen mode

Resend's only job: verify domains and deliver emails. Everything else lives in your database.


The Key Insight About Resend

Before writing a line of code, this is worth understanding clearly.

Resend has two separate worlds:

World 1: Domains + Emails (reliable, full API support)

  • Add and verify custom domains
  • Send single emails (POST /emails)
  • Send batch emails up to 100 at a time (POST /emails/batch)
  • Webhook delivery events

World 2: Audiences + Contacts + Broadcasts (limited via API)

  • Audiences and Contacts exist in the API, but Segments are dashboard-only — there's no filtering endpoint
  • You can't query "send only to contacts where plan = premium" — that endpoint doesn't exist
  • Domains and Contacts have no relationship in Resend's data model at all

Once I understood this, the solution became obvious: store contacts in my own database and use Resend only as the delivery engine.


Project Setup

pip install fastapi uvicorn sqlalchemy aiosqlite resend \
            python-dotenv "pydantic[email]" httpx svix
Enter fullscreen mode Exit fullscreen mode

For local development, aiosqlite gives you async SQLite with zero setup. Swap to asyncpg for PostgreSQL in production — no code changes needed.

Project structure:

resend-fastapi/
├── app/
│   ├── main.py
│   ├── models.py
│   ├── database.py
│   ├── routers/
│   │   ├── customers.py     # CRUD + domain setup
│   │   ├── contacts.py      # contacts + segments
│   │   ├── email.py         # send endpoints
│   │   ├── campaigns.py     # send history
│   │   └── webhooks.py      # Resend delivery events
│   └── services/
│       └── resend_service.py  # only file that calls Resend
├── tests/
│   ├── conftest.py
│   ├── test_customers.py
│   ├── test_contacts.py
│   ├── test_email.py
│   ├── test_campaigns.py
│   ├── test_webhooks.py
│   └── e2e/
│       └── test_api_e2e.py
├── .env
├── .env.example
├── pytest.ini
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

.env:

RESEND_API_KEY=re_your_api_key_here
# SQLite for local dev (default — no setup needed):
DATABASE_URL=sqlite+aiosqlite:///./resend_local.db
# PostgreSQL for production:
# DATABASE_URL=postgresql+asyncpg://user:password@localhost/emaildb
# Webhook signing secret (from Resend dashboard):
# WEBHOOK_SECRET=whsec_your_secret_here
Enter fullscreen mode Exit fullscreen mode

Database Models

One design choice worth calling out: I use SQLAlchemy's JSON column type instead of PostgreSQL-specific ARRAY or JSONB. This means the same model code runs on both SQLite (local dev) and PostgreSQL (production) without changes.

# app/models.py
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, JSON, Integer
from sqlalchemy.orm import relationship, DeclarativeBase
from datetime import datetime
import uuid


class Base(DeclarativeBase):
    pass


def new_id() -> str:
    return str(uuid.uuid4())


class Customer(Base):
    __tablename__ = "customers"

    id = Column(String, primary_key=True, default=new_id)
    name = Column(String(255), nullable=False)
    domain_name = Column(String(255), nullable=True)
    resend_domain_id = Column(String(255), nullable=True)
    domain_verified = Column(Boolean, default=False)
    created_at = Column(DateTime, default=datetime.utcnow)

    contacts = relationship("Contact", back_populates="customer", cascade="all, delete")
    segments = relationship("Segment", back_populates="customer", cascade="all, delete")


class Contact(Base):
    __tablename__ = "contacts"

    id = Column(String, primary_key=True, default=new_id)
    customer_id = Column(String, ForeignKey("customers.id"), nullable=False)
    email = Column(String(255), nullable=False)
    first_name = Column(String(100), nullable=True)
    last_name = Column(String(100), nullable=True)
    tags = Column(JSON, default=list)        # e.g. ["newsletter", "premium"]
    is_subscribed = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    customer = relationship("Customer", back_populates="contacts")


class Segment(Base):
    __tablename__ = "segments"

    id = Column(String, primary_key=True, default=new_id)
    customer_id = Column(String, ForeignKey("customers.id"), nullable=False)
    name = Column(String(255), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)

    customer = relationship("Customer", back_populates="segments")
    contacts = relationship("Contact", secondary="contact_segments", backref="segments")


class ContactSegment(Base):
    __tablename__ = "contact_segments"

    contact_id = Column(String, ForeignKey("contacts.id", ondelete="CASCADE"), primary_key=True)
    segment_id = Column(String, ForeignKey("segments.id", ondelete="CASCADE"), primary_key=True)
    added_at = Column(DateTime, default=datetime.utcnow)


class Campaign(Base):
    """Records every bulk send — a historical log you can query later."""
    __tablename__ = "campaigns"

    id = Column(String, primary_key=True, default=new_id)
    customer_id = Column(String, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
    subject = Column(String(500), nullable=False)
    sent_to_count = Column(Integer, default=0)
    from_address = Column(String(255))
    targeting = Column(JSON, default=dict)  # {"tag": "premium"} or {"segment_id": "..."}
    status = Column(String(50), default="sent")
    sent_at = Column(DateTime, default=datetime.utcnow)

    customer = relationship("Customer", backref="campaigns")
Enter fullscreen mode Exit fullscreen mode

The Campaign model is created automatically every time you call the send endpoint — no manual tracking needed.


Database Setup

# app/database.py
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from dotenv import load_dotenv
from .models import Base

load_dotenv()

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./resend_local.db")

# Only enable SQL logging in development — echo=True logs query values to stdout
_SQL_ECHO = os.getenv("DEBUG", "false").lower() == "true"
engine = create_async_engine(DATABASE_URL, echo=_SQL_ECHO)

AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


async def init_db():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)


async def get_db():
    async with AsyncSessionLocal() as session:
        yield session
Enter fullscreen mode Exit fullscreen mode

Note the echo flag — I learned the hard way that echo=True in production logs every SQL value (including email addresses) to stdout. Always tie it to a DEBUG environment variable.


Resend Service

This is the only file that talks to the Resend API. Everything else in the codebase uses the database directly.

# app/services/resend_service.py
import os
import resend
from dataclasses import dataclass
from typing import List
from dotenv import load_dotenv

load_dotenv()
resend.api_key = os.getenv("RESEND_API_KEY", "")


@dataclass
class EmailRecipient:
    email: str
    first_name: str = ""


# ── Domain management ─────────────────────────────────────────────────────────

def add_domain(domain_name: str) -> dict:
    """Register a domain with Resend. Returns DNS records for your customer."""
    return resend.Domains.create({"name": domain_name})


def verify_domain(domain_id: str) -> dict:
    """Ask Resend to check if DNS records have been added."""
    return resend.Domains.verify(domain_id)


def get_domain_status(domain_id: str) -> dict:
    """Poll verification status: not_started | pending | verified | failed"""
    return resend.Domains.get(domain_id)


# ── Email sending ─────────────────────────────────────────────────────────────

def send_single(from_domain: str, to_email: str, subject: str, html: str) -> dict:
    """Send one transactional email (confirmations, password resets, alerts)."""
    return resend.Emails.send({
        "from": f"hello@{from_domain}",
        "to": [to_email],
        "subject": subject,
        "html": html,
    })


def send_bulk(
    from_domain: str,
    recipients: List[EmailRecipient],
    subject: str,
    html_template: str
) -> List[dict]:
    """
    Send to any number of recipients by auto-splitting into batches of 100.
    Resend's batch endpoint limit is 100 emails per call.
    Replaces {{first_name}} per recipient — falls back to 'there' if no name.
    """
    results = []
    for i in range(0, len(recipients), 100):
        chunk = recipients[i:i + 100]
        emails = [
            {
                "from": f"hello@{from_domain}",
                "to": r.email,
                "subject": subject.replace("{{first_name}}", r.first_name or "there"),
                "html": html_template.replace("{{first_name}}", r.first_name or "there"),
            }
            for r in chunk
        ]
        results.append(resend.Batch.send(emails))
    return results
Enter fullscreen mode Exit fullscreen mode

Keeping all Resend calls in one file pays off when writing tests — you only need to mock one module.


Domain Management

The domain setup is two steps: register (get DNS records), then verify (after your customer adds those records).

# app/routers/customers.py  (domain endpoints)

@router.post("/{customer_id}/domains", summary="Step 1 — Register domain with Resend")
async def add_domain(customer_id: str, body: AddDomainRequest, db: AsyncSession = Depends(get_db)):
    customer = await _get_customer_or_404(customer_id, db)

    try:
        resend_resp = resend_service.add_domain(body.domain_name)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Resend error: {str(e)}")

    customer.domain_name = body.domain_name
    customer.resend_domain_id = resend_resp.get("id")
    customer.domain_verified = False
    await db.commit()

    return {
        "domain": body.domain_name,
        "resend_domain_id": resend_resp.get("id"),
        "dns_records": resend_resp.get("records", []),
        "next_step": f"Share dns_records with your customer, then call POST /customers/{customer_id}/domains/verify",
    }


@router.post("/{customer_id}/domains/verify", summary="Step 2 — Trigger verification")
async def verify_domain(customer_id: str, db: AsyncSession = Depends(get_db)):
    customer = await _get_customer_or_404(customer_id, db)
    if not customer.resend_domain_id:
        raise HTTPException(status_code=400, detail="No domain registered. Call /domains first.")

    resend_service.verify_domain(customer.resend_domain_id)
    status_resp = resend_service.get_domain_status(customer.resend_domain_id)
    is_verified = status_resp.get("status") == "verified"

    if is_verified:
        customer.domain_verified = True
        await db.commit()

    return {
        "domain": customer.domain_name,
        "verified": is_verified,
        "status": status_resp.get("status"),
        "message": "Domain verified. Emails can now be sent."
                   if is_verified else
                   "Not verified yet. DNS changes can take up to 48 hours.",
    }
Enter fullscreen mode Exit fullscreen mode

DNS propagation is real. Even after a customer correctly adds their DNS records, verification can take up to 48 hours. Build a polling endpoint (GET /customers/{id}/domains/status) and have your frontend check it every 30–60 seconds rather than expecting instant success.


Contact and Segment Management

Contacts live entirely in your database. Nothing is sent to Resend when you add or update a contact — Resend only gets involved at send time.

Key points in the contact routes:

Duplicate guard — same email cannot be added twice under the same customer:

existing = await db.execute(
    select(Contact).where(
        and_(Contact.customer_id == customer_id, Contact.email == body.email)
    )
)
if existing.scalar_one_or_none():
    raise HTTPException(status_code=409, detail=f"Contact with email '{body.email}' already exists.")
Enter fullscreen mode Exit fullscreen mode

Tag filtering — because JSON doesn't support array operators like PostgreSQL's @>, filtering by tag is done in Python after the DB query:

contacts_result = await db.execute(query)
contacts = contacts_result.scalars().all()

if body.tag:
    contacts = [c for c in contacts if c.tags and body.tag in c.tags]
Enter fullscreen mode Exit fullscreen mode

This is fine at the scale of most SaaS apps. If you're dealing with millions of contacts, switch to PostgreSQL's JSONB with GIN indexes for this column.

Segments are named groups you manage explicitly. You create a segment, then add contact IDs to it:

POST /customers/{id}/segments          → create segment
POST /customers/{id}/segments/{sid}/contacts  → add contacts
Enter fullscreen mode Exit fullscreen mode

Contacts added to a wrong customer's segment are silently skipped — no error, no leak.


Sending Emails

The send endpoint handles three targeting modes — all contacts, by tag, or by segment:

# app/routers/email.py

@router.post("/{customer_id}/send")
async def send_email(customer_id: str, body: SendEmailRequest, db: AsyncSession = Depends(get_db)):
    customer = await _get_verified_customer_or_error(customer_id, db)

    # ── Resolve target contacts ───────────────────────────────────────────────
    if body.segment_id:
        query = (
            select(Contact)
            .join(ContactSegment, Contact.id == ContactSegment.contact_id)
            .where(
                ContactSegment.segment_id == body.segment_id,
                Contact.customer_id == customer_id,
                Contact.is_subscribed == True,
            )
        )
    else:
        query = select(Contact).where(
            Contact.customer_id == customer_id,
            Contact.is_subscribed == True,
        )

    contacts_result = await db.execute(query)
    contacts = contacts_result.scalars().all()

    # Tag filter applied in Python (JSON column — no DB-level array operator)
    if body.tag and not body.segment_id:
        contacts = [c for c in contacts if c.tags and body.tag in c.tags]

    if not contacts:
        raise HTTPException(status_code=400, detail="No subscribed contacts match targeting criteria.")

    recipients = [EmailRecipient(email=c.email, first_name=c.first_name or "") for c in contacts]

    # ── Send (auto-batches every 100 recipients) ──────────────────────────────
    try:
        results = send_bulk(
            from_domain=customer.domain_name,
            recipients=recipients,
            subject=body.subject,
            html_template=body.html,
        )
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"Resend delivery error: {str(e)}")

    # ── Record campaign history ───────────────────────────────────────────────
    targeting = {}
    if body.segment_id:
        targeting = {"segment_id": body.segment_id}
    elif body.tag:
        targeting = {"tag": body.tag}

    campaign = Campaign(
        customer_id=customer_id,
        subject=body.subject,
        sent_to_count=len(recipients),
        from_address=f"hello@{customer.domain_name}",
        targeting=targeting,
        status="sent",
    )
    db.add(campaign)
    await db.commit()

    return {
        "success": True,
        "campaign_id": campaign.id,
        "sent_to": len(recipients),
        "from": f"hello@{customer.domain_name}",
        "batches_used": len(results),
    }
Enter fullscreen mode Exit fullscreen mode

The segment_id takes priority if both segment_id and tag are provided — one clear rule, no ambiguity.


Campaign History

Every send automatically creates a Campaign record. The campaign routes let you retrieve and clean up that history:

# GET /customers/{id}/campaigns       — list all, newest first
# GET /customers/{id}/campaigns/{cid} — single campaign
# DELETE /customers/{id}/campaigns/{cid} — remove record (does NOT unsend emails)
Enter fullscreen mode Exit fullscreen mode

The delete endpoint is worth a comment — it removes the database record only. Emails already sent cannot be recalled. This is useful for cleaning up test sends during development.


Handling Resend Webhooks

Resend uses Svix to deliver webhooks. Every event POST includes three signature headers you verify with HMAC-SHA256.

# app/routers/webhooks.py
import os, hmac, hashlib, base64, logging
from fastapi import APIRouter, Request, HTTPException, Header
from typing import Optional

router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
logger = logging.getLogger(__name__)


def _get_secret() -> str:
    """Read per-request so runtime environment changes are picked up."""
    return os.getenv("WEBHOOK_SECRET", "")


def _verify_svix_signature(
    payload: bytes, svix_id: str, svix_timestamp: str, svix_signature: str, secret: str
) -> bool:
    if secret.startswith("whsec_"):
        secret_bytes = base64.b64decode(secret[6:])
    else:
        secret_bytes = secret.encode()

    signed_content = f"{svix_id}.{svix_timestamp}.".encode() + payload
    expected = base64.b64encode(
        hmac.new(secret_bytes, signed_content, hashlib.sha256).digest()
    ).decode()

    for sig in svix_signature.split(" "):
        if sig.startswith("v1,") and hmac.compare_digest(expected, sig[3:]):
            return True
    return False


@router.post("/resend")
async def handle_resend_webhook(
    request: Request,
    svix_id: Optional[str] = Header(None, alias="svix-id"),
    svix_timestamp: Optional[str] = Header(None, alias="svix-timestamp"),
    svix_signature: Optional[str] = Header(None, alias="svix-signature"),
):
    payload = await request.body()
    secret = _get_secret()

    if secret:
        if not all([svix_id, svix_timestamp, svix_signature]):
            raise HTTPException(status_code=400, detail="Missing svix-* signature headers.")
        if not _verify_svix_signature(payload, svix_id, svix_timestamp, svix_signature, secret):
            raise HTTPException(status_code=401, detail="Webhook signature verification failed.")

    event = await request.json()
    event_type = event.get("type", "")
    data = event.get("data", {})

    if event_type == "email.bounced":
        to = data.get("to", [])
        logger.info(f"Hard bounce for {to} — marking unsubscribed")
        # TODO: set Contact.is_subscribed = False for each address in `to`

    elif event_type == "email.complained":
        to = data.get("to", [])
        logger.info(f"Spam complaint from {to} — unsubscribing immediately")
        # TODO: set Contact.is_subscribed = False

    # Always return 200 — anything else causes Resend to retry for 24 hours
    return {"received": True, "type": event_type}
Enter fullscreen mode Exit fullscreen mode

Three things worth noting here:

1. Read the secret per-request, not at import time. If you read WEBHOOK_SECRET as a module-level constant, changes to the environment variable after startup have no effect. Reading it per-request inside _get_secret() avoids this.

2. compare_digest prevents timing attacks. Using == to compare signatures leaks information through response time differences. hmac.compare_digest takes constant time regardless of where a mismatch occurs.

3. Always return 200. Resend retries webhook delivery on any non-2xx response. Your handler should be idempotent — processing the same event twice should have no side effects.

Local webhook testing: Use npx svix-cli listen http://localhost:8000/webhooks/resend to get a public forwarding URL. Register that URL in your Resend dashboard, copy the signing secret into .env, and real webhook events will reach your local server.


Testing Without a Real Domain

This was the part I found most valuable to figure out. You can write a complete test suite for this entire service — all 88 tests — without a verified domain, without spending any Resend API credits, and without setting up a webhook endpoint.

The approach: mock the one module that calls Resend, and use an in-memory SQLite database for everything else.

# tests/conftest.py
import pytest_asyncio
from unittest.mock import patch, MagicMock
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.main import app
from app.database import get_db
from app.models import Base

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"


@pytest_asyncio.fixture(scope="function")
async def db_session():
    engine = create_async_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    async with SessionLocal() as session:
        yield session
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()


@pytest_asyncio.fixture(scope="function")
async def client(db_session: AsyncSession):
    async def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db

    with (
        patch("app.routers.customers.resend_service.add_domain",
              MagicMock(return_value={"id": "dom_test", "status": "not_started", "records": []})),
        patch("app.routers.customers.resend_service.verify_domain", MagicMock()),
        patch("app.routers.customers.resend_service.get_domain_status",
              MagicMock(return_value={"status": "verified"})),
        patch("app.routers.email.send_single", MagicMock(return_value={"id": "email_abc"})),
        patch("app.routers.email.send_bulk",   MagicMock(return_value=[{"data": []}])),
    ):
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
            yield ac

    app.dependency_overrides.clear()
Enter fullscreen mode Exit fullscreen mode

With this in place, a test that verifies the full send flow looks like this:

# tests/test_email.py

async def test_send_filtered_by_tag(client):
    # Create customer with verified domain
    c = await client.post("/customers/", json={"name": "Acme"})
    cid = c.json()["id"]
    await client.post(f"/customers/{cid}/domains", json={"domain_name": "acme.com"})
    await client.post(f"/customers/{cid}/domains/verify")

    # Add contacts with different tags
    await client.post(f"/customers/{cid}/contacts",
                      json={"email": "premium@acme.com", "tags": ["premium"]})
    await client.post(f"/customers/{cid}/contacts",
                      json={"email": "free@acme.com", "tags": ["free"]})

    # Send only to premium tag — should reach 1 contact
    r = await client.post(f"/customers/{cid}/send", json={
        "subject": "VIP offer",
        "html": "<p>Exclusive for you</p>",
        "tag": "premium",
    })

    assert r.status_code == 200
    assert r.json()["sent_to"] == 1
    assert "campaign_id" in r.json()  # campaign was recorded
Enter fullscreen mode Exit fullscreen mode

The test runs in milliseconds with no network calls. The mock domain verification always returns "verified" so you can test every send scenario without touching a real Resend account.

For the webhook tests, you generate valid Svix signatures in the test itself:

def _make_svix_headers(payload: bytes, secret: str) -> dict:
    if secret.startswith("whsec_"):
        secret_bytes = base64.b64decode(secret[6:])
    else:
        secret_bytes = secret.encode()

    svix_id = "msg_test01"
    svix_timestamp = "1713000000"
    signed_content = f"{svix_id}.{svix_timestamp}.".encode() + payload
    digest = hmac.new(secret_bytes, signed_content, hashlib.sha256).digest()
    sig = base64.b64encode(digest).decode()

    return {
        "svix-id": svix_id,
        "svix-timestamp": svix_timestamp,
        "svix-signature": f"v1,{sig}",
    }
Enter fullscreen mode Exit fullscreen mode

Call this in your test with a known secret, patch _get_secret() to return that secret, and you can fully test both the valid and tampered-signature paths — no real Resend webhook needed.

On top of the unit tests, there are 16 Playwright end-to-end tests that spin up a real uvicorn server and hit it over actual HTTP. This catches issues the httpx tests miss: middleware ordering, serialisation edge cases, and real TCP behaviour. The setup is in tests/e2e/conftest.py — the same Resend mocks apply, just applied before the server starts.


App Entry Point

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .database import init_db
from .routers import customers, contacts, email, webhooks, campaigns


@asynccontextmanager
async def lifespan(app: FastAPI):
    await init_db()   # creates tables on startup
    yield


app = FastAPI(
    title="Multi-Tenant Email Service — Resend + FastAPI",
    lifespan=lifespan,
)

app.include_router(customers.router)
app.include_router(contacts.router)
app.include_router(email.router)
app.include_router(campaigns.router)
app.include_router(webhooks.router)
Enter fullscreen mode Exit fullscreen mode

Using lifespan instead of the deprecated @app.on_event("startup") — FastAPI has recommended this pattern since version 0.93.


Security Considerations

A few things I'd flag before putting this in front of real traffic:

There is no authentication on any endpoint. This is intentional for the article — adding auth is a separate concern and would double the length. In production, add at minimum a shared X-API-Key header check, or preferably per-customer bearer tokens. Without this, anyone who can reach your server can read and delete all customer data.

Rate limit the /send endpoints. An unprotected send endpoint is an open relay for spam. Add slowapi or similar middleware before exposing this publicly.

Set WEBHOOK_SECRET in production. Without it, anyone can POST to /webhooks/resend and your handler will process it. The startup warning in the logs is a reminder, not a substitute for setting the variable.

Keep DEBUG=false in production. With DEBUG=true, every SQL query — including the values of email addresses — goes to stdout.


The Complete Flow in Practice

1. Onboard a customer
   POST /customers/
   POST /customers/{id}/domains  → get DNS records, give to customer

2. Wait for DNS propagation (up to 48 hours)
   GET /customers/{id}/domains/status  → poll until "verified"

3. Add contacts
   POST /customers/{id}/contacts  → stored in your DB, nothing goes to Resend

4. Create segments (optional)
   POST /customers/{id}/segments
   POST /customers/{id}/segments/{sid}/contacts

5. Send a campaign
   POST /customers/{id}/send
   {"subject": "Hello {{first_name}}!", "html": "...", "tag": "premium"}
   → fetches contacts from DB, sends via Resend in batches of 100
   → records a Campaign row automatically

6. Receive delivery events
   POST /webhooks/resend  → auto-unsubscribes bounces and complaints
Enter fullscreen mode Exit fullscreen mode

What I'd Do Differently

1. Don't rely on Resend's Audiences for any production filtering. The feature exists in the dashboard, but the API has no filtering endpoint for Segments. If you need any filtering by attribute — tag, plan, activity date — store it yourself.

2. Build DNS verification polling from day one. Customers expect to see something updating on screen. A simple GET /customers/{id}/domains/status endpoint that your frontend polls every 30 seconds is much better UX than a spinner that does nothing.

3. Webhook endpoint must be live before you start integration testing. Resend validates the URL you register. Set it up first — even if the handler just returns {"received": true} — before testing delivery events end-to-end.

4. Unsubscribe handling is not optional. Resend (like every email provider) monitors your bounce rate. Too many bounces and your account gets flagged. Build the auto-unsubscribe logic in from day one, not as a follow-up task.

5. Use aiosqlite locally, asyncpg in production. Switching between them is one line in .env — no code changes. It saves a lot of setup time during development.


Summary

Resend is an excellent email delivery service — fast, reliable, and the API is genuinely clean. But it's a sending engine, not a CRM. If you're building anything multi-tenant or anything that needs contact segmentation, own that layer yourself.

The pattern in this article gives you:

  • Per-customer domain isolation with full DNS verification flow
  • Complete contact and segment management in your own database
  • Scalable batch sending with {{first_name}} personalisation
  • Campaign history recorded on every send
  • Delivery tracking and auto-unsubscribe via webhooks
  • A full test suite that runs offline with no API credits

The full working code is on GitHub: https://github.com/srinivaspavuluri/resend-fastapi


Ran into a different edge case with Resend, or built something on top of this pattern? Drop it in the comments.

Top comments (0)