Browse the docs

Frameworks and agents · Updated 2026-09-28

Send email from Django

A Django view that checks the address, sends one shipping notice per order with an idempotency key, and returns the message id or the code and fix of a refusal.

To send email from Django, add one view that takes a POST, 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 file below is that view, with its settings inline so it runs without a project. It is executed against a real API on every build of this site's repository, through Django's own test Client: one notice, the same request again replaying it, a malformed address answered before any request is made, and the same order's notice to a second address refused by the API.

Install

pip install django

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 into your project.

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 view

"""Django: one view that sends a shipping notice from a POST.

    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

In a project, `send` goes in views.py and the path in urls.py; the settings
block below is only there so this file runs on its own, and is skipped when
Django is already configured.

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

from __future__ import annotations

import json
import os
import re
import secrets

import django
from django.conf import settings

if not settings.configured:
    settings.configure(
        ROOT_URLCONF=__name__,
        ALLOWED_HOSTS=["localhost", "127.0.0.1"],
        # No sessions or signed cookies here, so a per-process key is enough.
        SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY") or secrets.token_urlsafe(50),
    )
    django.setup()

from django.http import HttpRequest, JsonResponse  # noqa: E402
from django.urls import path  # noqa: E402
from django.views.decorators.csrf import csrf_exempt  # noqa: E402
from django.views.decorators.http import require_POST  # noqa: E402

from agentisend import AgentiSend, AgentiSendError, idempotency_key  # noqa: E402

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]+$")


@csrf_exempt  # a JSON endpoint called by your own backend; keep CSRF for browser forms
@require_POST
def send(request: HttpRequest) -> JsonResponse:
    try:
        body = json.loads(request.body or b"{}")
    except ValueError:
        body = {}
    email = str(body.get("email", "")).strip()
    order_id = str(body.get("order_id", "")).strip()
    if not ADDRESS.match(email):
        return JsonResponse({"error": "email must be one address, like you@example.com"}, status=400)
    if not order_id:
        return JsonResponse({"error": "order_id is required"}, status=400)

    try:
        sent = agentisend.send_email(
            {
                "from": MAIL_FROM,
                "to": email,
                "subject": f"Order {order_id} has shipped",
                "text": "Tracking details are in your account.",
            },
            # One notice per shipped order: a retry after a timeout replays
            # the first send instead of mailing the customer twice.
            idempotency=idempotency_key("shipped", order_id),
        )
    except AgentiSendError as err:
        return JsonResponse({"code": err.code, "fix": err.fix}, status=err.status)
    return JsonResponse({"id": sent["id"]})


urlpatterns = [path("send", send)]
examples/django/app.py, executed against the live API on every build

In a project

Move send into your app's views.py, drop the settings.configure block, and route to it:

# urls.py
from django.urls import path

from . import views

urlpatterns = [path("send", views.send)]

The view is csrf_exempt because it is a JSON endpoint called by your own backend. A form a browser posts keeps Django's CSRF check: remove the decorator and send the token with the form.

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 view 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: shipped/<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 notice. The same key with a different body is refused with idempotency_payload_mismatch: an order ships once, and a request that tries to announce it to someone 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 view 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