Skip to content

Send email with FastAPI

Send transactional email from FastAPI through PostStack.

FastAPI is async-first, so reach for the AsyncPostStack client and await it directly inside your route handlers — the SDK uses httpx under the hood and shares a connection pool, which keeps tail latency low under load. Create the client once in a lifespan context manager, expose it through a Depends dependency, and you get clean injection plus a single pool for the whole app. For fire-and-forget notifications, BackgroundTasks is enough at low volume; for high throughput, hand off to arq or Celery.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. Install the SDK

    bash
    pip install poststack "fastapi[standard]"
  2. Initialize the client

    python
    # main.py — create one AsyncPostStack for the app lifespan
    import os
    from contextlib import asynccontextmanager
    from fastapi import FastAPI
    from poststack import AsyncPostStack
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        # The async client is itself an async context manager, so open it for the
        # app's lifetime and let it clean up its connection pool on shutdown.
        async with AsyncPostStack(api_key=os.environ["POSTSTACK_API_KEY"]) as ps:
            app.state.poststack = ps
            yield
    
    app = FastAPI(lifespan=lifespan)
    
    # dependency — inject the shared client into route handlers
    from fastapi import Request
    
    def get_poststack(request: Request) -> AsyncPostStack:
        return request.app.state.poststack
  3. Send an email

    python
    from fastapi import Depends, FastAPI
    from pydantic import BaseModel, EmailStr
    from poststack import AsyncPostStack
    
    class SignupBody(BaseModel):
        email: EmailStr
        name: str
    
    @app.post("/signup")
    async def signup(
        body: SignupBody,
        ps: AsyncPostStack = Depends(get_poststack),
    ):
        user = await create_user(body)
        # await the async client directly inside the async handler
        result = await ps.emails.send({
            "from": "hello@yourdomain.com",
            "to": [body.email],
            "subject": "Welcome to Acme",
            "html": f"<h1>Welcome, {body.name}!</h1>",
        })
        return {"ok": True, "email_id": result["id"]}
    
    # Fire-and-forget with BackgroundTasks (low volume)
    from fastapi import BackgroundTasks
    
    @app.post("/notify")
    async def notify(body: SignupBody, bg: BackgroundTasks, ps: AsyncPostStack = Depends(get_poststack)):
        bg.add_task(ps.emails.send, {
            "from": "hello@yourdomain.com",
            "to": [body.email],
            "subject": "Heads up",
            "html": "<p>Something happened.</p>",
        })
        return {"queued": True}
  4. Handle errors

    python
    import logging
    from poststack import AsyncPostStack, PostStackError
    
    logger = logging.getLogger(__name__)
    
    async def send(ps: AsyncPostStack, payload):
        # The async client retries 429/5xx internally; handle terminal errors here.
        try:
            return await ps.emails.send(payload)
        except PostStackError as e:
            if 400 <= e.status_code < 500 and e.status_code != 429:
                logger.error("PostStack %s: %s (req %s)", e.code, e.error, e.request_id)
                return None
            raise

Good to know

  • Use AsyncPostStack and await it directly inside async def handlers
  • Create the client in lifespan and inject it with Depends so one connection pool is shared

Framework integrations#

Lifespan + dependency injection

Construct AsyncPostStack in an @asynccontextmanager lifespan, store it on app.state, and expose a get_poststack dependency. Handlers receive the shared client via Depends, so there is exactly one HTTP connection pool for the process.

BackgroundTasks for fire-and-forget

For low-volume notifications, bg.add_task(ps.emails.send, payload) returns the response before the email is sent. Note BackgroundTasks run in-process after the response — they do not survive a crash, so use a real queue for anything you must not lose.

arq / Celery for high volume

When you send at scale or need durable retries, enqueue to arq (async-native) or Celery instead of BackgroundTasks. The worker constructs its own AsyncPostStack and awaits the send; the SDK retries transient API errors itself, so the worker only re-queues on a terminal PostStackError.

Pydantic request models

Validate recipient addresses with EmailStr on your request model so malformed input is rejected before it ever reaches the API — fewer 4xx errors and cleaner suppression lists.

Common pitfalls#

Using the sync client in an async handler

Calling the synchronous PostStack client inside async def blocks the event loop and collapses throughput. Always use AsyncPostStack in async code, or wrap sync calls in asyncio.to_thread.

Forgetting to await the send

await ps.emails.send(...) — without await you get a coroutine object and no email is ever sent. Enable a type checker (mypy/pyright) to catch the missing await.

Relying on BackgroundTasks for critical mail

BackgroundTasks run in-process and are lost if the worker restarts mid-task. For receipts and password resets, use a durable queue (arq/Celery), not BackgroundTasks.

FAQ#

How do I send email in a FastAPI route?

Use AsyncPostStack, inject it with Depends, and await ps.emails.send(...) directly in your async def handler. Create the client once in a lifespan context manager so the whole app shares one connection pool.

Should I use BackgroundTasks or a queue?

BackgroundTasks is fine for low-volume, non-critical notifications — it runs after the response is sent. For mail you must not lose (receipts, password resets) or high volume, use a durable queue like arq or Celery.

Can I use the synchronous client in FastAPI?

You can, but only if you wrap it in asyncio.to_thread — calling the sync client directly in an async def blocks the event loop. The async client (AsyncPostStack) is the right default for FastAPI.

Reference#

The docs this guide builds on:

  • SDKs — every official client, its retries and idempotency keys
  • SMTP relay — hosts, ports, TLS modes and the X-PostStack-* headers
  • Emails API — every send field, attachments, scheduling and batch sends
  • Templates — stored templates with variables
  • Webhooks — event types, payloads and signature verification
  • Suppressions — what happens to an address after a hard bounce or complaint

Ready to send from FastAPI?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?