Frameworks and agents · Updated 2026-09-28
Send email from Flask
A Flask POST route that checks the address, sends one receipt per order with an idempotency key, and returns the message id or the code and fix of a refusal.
To send email from Flask, add one POST route that checks the address, calls AgentiSend with an idempotency key derived from the order, 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 Flask's own test_client(): one receipt, the same request again replaying it, a malformed address answered before any request is made, and the same order's receipt to a second address refused by the API.
Install
pip install flaskThe 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
"""Flask: one POST route that sends an order receipt.
POST /send {"email": "ada@example.com", "order_id": "1042"}
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: flask --app app run
"""
from __future__ import annotations
import os
import re
from flask import Flask, jsonify, request
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 = Flask(__name__)
@app.post("/send")
def send():
body = request.get_json(silent=True) or {}
email = str(body.get("email", "")).strip()
order_id = str(body.get("order_id", "")).strip()
if not ADDRESS.match(email):
return jsonify(error="email must be one address, like you@example.com"), 400
if not order_id:
return jsonify(error="order_id is required"), 400
try:
sent = agentisend.send_email(
{
"from": MAIL_FROM,
"to": email,
"subject": f"Receipt for order {order_id}",
"text": f"Thanks for your order. Order {order_id} is paid.",
},
# One receipt per order, whoever asks and however often: a retry
# after a timeout replays the first send instead of mailing twice.
idempotency=idempotency_key("receipt", order_id),
)
except AgentiSendError as err:
return jsonify(code=err.code, fix=err.fix), err.status
return jsonify(id=sent["id"])Start it
flask --app app run
curl -X POST localhost:5000/send \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","order_id":"1042"}'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: receipt/<order>, derived from what the message is and never from when it was asked for. A retry after a timeout gets the first send's id back instead of a second receipt. The same key with a different body is refused with idempotency_payload_mismatch: an order has one receipt, and a request that tries to send it somewhere else is told so rather than sent. 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