Browse the docs

Frameworks and agents · Updated 2026-09-28

Send email from FastAPI

A FastAPI POST route that checks the address, sends with an idempotency key, and returns the message id or the code and fix of a refusal.

To send email from FastAPI, add one POST route that checks the address, calls AgentiSend with an idempotency key derived from the recipient, and returns the message id, or the refusal's code and fix. The app below is that route. It is executed against a real API on every build of this site's repository, through FastAPI's own TestClient: one send, the same request again replaying it, a malformed address answered before any request is made, and a changed body under the same key refused by the API.

Install

pip install fastapi uvicorn

The agentisend package is standard library only. It is not on the Python package index yet: until it is, install it from the SDK directory of a checkout with pip install ./packages/sdk-python, or copy its agentisend folder beside app.py.

Environment

VariableRequiredWhat it is
AGENTISEND_API_KEYyesA key with sending_access, and a budget on it if software decides when to send.
MAIL_FROMyesThe From address, on a domain you have verified.
AGENTISEND_BASE_URLnoDefaults to https://api.agentisend.com.

The route

"""FastAPI: one POST route that sends a welcome email.

    POST /send  {"email": "ada@example.com", "name": "Ada"}
    200         {"id": "..."}                     accepted
    400         {"error": "..."}                  not one address; the API was not called
    4xx/5xx     {"code": "...", "fix": "..."}     the API refused, and says what to do

Environment: AGENTISEND_API_KEY (a key with sending_access), MAIL_FROM (an
address on a domain you have verified), and optionally AGENTISEND_BASE_URL.
Run: uvicorn app:app
"""

from __future__ import annotations

import os
import re

from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from agentisend import AgentiSend, AgentiSendError, idempotency_key

agentisend = AgentiSend()  # reads AGENTISEND_API_KEY and AGENTISEND_BASE_URL
MAIL_FROM = os.environ["MAIL_FROM"]

# One address, no spaces, a dot in the domain. The API checks properly; this
# only stops a form typo from costing a request.
ADDRESS = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

app = FastAPI()


class Signup(BaseModel):
    email: str
    name: str = "there"


@app.post("/send")
def send(signup: Signup) -> JSONResponse:
    email = signup.email.strip()
    if not ADDRESS.match(email):
        return JSONResponse({"error": "email must be one address, like you@example.com"}, status_code=400)

    try:
        sent = agentisend.send_email(
            {
                "from": MAIL_FROM,
                "to": email,
                "subject": "Welcome",
                "text": f"Hi {signup.name}, your account is ready.",
            },
            # Derived from the thing being done, never from the moment: a retry
            # after a timeout replays the first send instead of mailing twice.
            idempotency=idempotency_key("welcome", email),
        )
    except AgentiSendError as err:
        return JSONResponse({"code": err.code, "fix": err.fix}, status_code=err.status)
    return JSONResponse({"id": sent["id"]})
examples/fastapi/app.py, executed against the live API on every build

The route is a plain def, so FastAPI runs it in its thread pool and the SDK's blocking request never holds up the event loop.

Start it

uvicorn app:app
curl -X POST localhost:8000/send \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","name":"Ada"}'

The key it holds

Give the app a key of its own rather than yours, and put a ceiling on it before it goes live:

  • POST /api-keys with sending_access mints a key that can send and read the mail it sent itself, and nothing else. It cannot touch your domains, read mail that arrived, or mint further keys.
  • PATCH /limits/keys/{id} sets budget_per_period and period. Past the ceiling, the route answers with agent_budget_exceeded and a fix that says when the period resets and that raising the budget is a person's decision. The key cannot raise its own.

The three calls are executed in Send email from Python.

The idempotency key

Every send carries Idempotency-Key: welcome/<address>, derived from what the message is and never from when it was asked for. A client that retries a timed-out request gets the first send's id back instead of mailing the same person twice. The same key with a different body is refused with idempotency_payload_mismatch, which is what the executed run's last request shows: one person, one welcome. Choose the key so that two different messages never share one.

What comes back when it goes wrong

Every refused request carries code, message, fix and docs_url. The route answers with the API's status and forwards code and fix, so the caller reads what to do rather than a number. The error catalogue lists every code.

Next