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 uvicornThe 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
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access, and a budget on it if software decides when to send. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults 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"]})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-keyswithsending_accessmints 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}setsbudget_per_periodandperiod. Past the ceiling, the route answers withagent_budget_exceededand afixthat 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
- Send email from Python, the same key and ceiling with the standard library
- Quickstart
- Email for AI agents