> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP in the EU, or send-us.ahasend.com as a US connection point forwarding to the EU. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# Send Email from Azure Functions (Python)

> Send email from Azure Functions in Python using AhaSend: install the v2 programming model, run a protected function locally and test in sandbox mode.

AhaSend lets you send transactional email from Azure Functions in Python through the HTTP API; this guide adds a protected function with a local test.

## Prerequisites

Use a [verified sending domain](/docs/domains), your account ID, and a domain-scoped [send-only API v2 key](/docs/send-api/credentials). Use [sandbox mode](/docs/send-api/sandbox) for every test. Management tasks need a separate [full API key](/docs/api-reference/authentication).

Use Python 3.13, [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) and the [Python v2 programming model](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python).

## Install the Dependencies

```bash theme={null}
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install azure-functions
python -m pip freeze > requirements.txt
```

## Try a Sandbox Send

Set `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID` and `AHASEND_FROM` in your shell or local secret store. Use a [send-only key](/docs/send-api/credentials) and an address on your [verified domain](/docs/domains). Save this as `quick_send.py`:

```python quick_send.py theme={null}
import json, os, urllib.request
payload = {
    "from": {"email": os.environ["AHASEND_FROM"]},
    "recipients": [{"email": "recipient@example.com"}],
    "subject": "My first sandbox email",
    "text_content": "Hello from AhaSend",
    "sandbox": True,
}
url = f"https://api.ahasend.com/v2/accounts/{os.environ['AHASEND_ACCOUNT_ID']}/messages"
request = urllib.request.Request(url, json.dumps(payload).encode(), headers={
    "Authorization": f"Bearer {os.environ['AHASEND_API_KEY']}",
    "Content-Type": "application/json",
})
with urllib.request.urlopen(request, timeout=10) as response:
    statuses = [item["status"] for item in json.load(response)["data"]]
if not statuses or any(s not in ("queued", "scheduled") for s in statuses):
    raise RuntimeError("A recipient was rejected")
print({"statuses": statuses})
```

Run `python quick_send.py` in the activated environment. It prints acceptance statuses and exits with an error if sending fails. Sandbox acceptance does not deliver mail. The production version below adds input checks and a stable request key.

## Add the Shared Sending Code

Save this as `send_email.py` beside the function file. It uses Python's standard library, so this example needs no SDK package. Set `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, `AHASEND_FROM`, `AHASEND_TO` and `SEND_TOKEN` in the runtime environment. Use a long random `SEND_TOKEN` for requests from your trusted server. For the first run set `AHASEND_TO=recipient@example.com` and `AHASEND_DELIVERY_MODE=sandbox`; keep a sandbox credential in place while testing. Set `live` only when ready to deliver to your own approved recipients.

```python send_email.py theme={null}
import hmac, json, os, re, urllib.error, urllib.request

def send_email(body, authorization):
    token = os.environ.get("SEND_TOKEN", "")
    if not token:
        return 500, {"error": "Missing server configuration"}
    if not hmac.compare_digest(authorization.encode(), ("Bearer " + token).encode()):
        return 401, {"error": "Unauthorized"}
    event_id = body.get("event_id") if isinstance(body, dict) else None
    if not isinstance(event_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", event_id):
        return 400, {"error": "event_id must be 8-64 letters, digits, _ or -"}
    mode = os.environ.get("AHASEND_DELIVERY_MODE", "sandbox")
    required = ("AHASEND_API_KEY", "AHASEND_ACCOUNT_ID", "AHASEND_FROM", "AHASEND_TO")
    if mode not in ("sandbox", "live") or any(not os.environ.get(k) for k in required):
        return 500, {"error": "Missing or invalid server configuration"}
    payload = {
        "from": {"email": os.environ["AHASEND_FROM"]},
        "recipients": [{"email": os.environ["AHASEND_TO"]}],
        "subject": "Welcome",
        "text_content": "Your account is ready.",
        "sandbox": mode == "sandbox",
    }
    url = f"https://api.ahasend.com/v2/accounts/{os.environ['AHASEND_ACCOUNT_ID']}/messages"
    request = urllib.request.Request(url, json.dumps(payload).encode(), headers={
        "Authorization": f"Bearer {os.environ['AHASEND_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"{mode}-welcome-{event_id}",
    })
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            result = json.load(response)
        statuses = [item["status"] for item in result["data"]]
        if not statuses or any(s not in ("queued", "scheduled") for s in statuses):
            return 422, {"error": "A recipient was rejected"}
        return 202, {"statuses": statuses}
    except (urllib.error.URLError, TimeoutError, ValueError, KeyError, TypeError):
        return 502, {"error": "Email request failed"}
```

Only your server chooses the sender, recipient and message body. Look up a user's verified address from your database before adapting this to a real signup flow. Keep that saved message unchanged when retrying an `event_id`; a new business event needs a new ID. See [idempotency](/docs/api-reference/idempotency) for time limits and uncertain outcomes. Record an internal event ID on failures without logging API keys or message bodies. Add request limits at the hosting layer.

## Create the HTTP Function

Save this as `function_app.py`:

```python function_app.py theme={null}
import json
import azure.functions as func
from send_email import send_email
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)

@app.route(route="sendEmail", methods=["POST"])
def send_email_http(req: func.HttpRequest) -> func.HttpResponse:
    raw = req.get_body()
    if len(raw) > 16384:
        return func.HttpResponse(status_code=413)
    try:
        body = json.loads(raw)
    except (ValueError, UnicodeError):
        return func.HttpResponse("Invalid JSON", status_code=400)
    status, result = send_email(body, req.headers.get("authorization", ""))
    return func.HttpResponse(json.dumps(result), status_code=status, mimetype="application/json")
```

Create `host.json`:

```json host.json theme={null}
{"version":"2.0"}
```

## Run with Core Tools

Activate `.venv`, export the variables from the shared code, and run:

```bash theme={null}
FUNCTIONS_WORKER_RUNTIME=python func start
```

Keep secrets, `.venv` and `local.settings.json` out of Git. In another shell with `SEND_TOKEN` set, run:

```bash theme={null}
curl --fail-with-body http://localhost:7071/api/sendEmail   -H "Authorization: Bearer $SEND_TOKEN" -H 'Content-Type: application/json'   -d '{"event_id":"local-azure-python-001"}'
```

Expect HTTP 202 with acceptance statuses. Invalid JSON or event IDs return 400; a wrong bearer token returns 401. Use a new event ID for a new test and the original ID for a retry of the same message. Local Core Tools does not enforce function keys; the bearer check still runs.

## Prepare the Hosted App

Select Python 3.13 on a supported Linux Functions plan. Include `requirements.txt`, `host.json`, `function_app.py` and `send_email.py` in the project. Keep secrets in [Key Vault references](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) and ordinary configuration in app settings. Add `x-functions-key` to hosted requests alongside the bearer token. Follow [Microsoft's Python deployment guidance](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python) and enforce your application's permissions and request limits.

Track final delivery with [webhooks](/docs/integrations/webhooks). Also see [Azure in Node.js](/docs/guides/azure-functions-nodejs), [Azure in Go](/docs/guides/azure-functions) and [Python SMTP](/docs/smtp/python).

## Related Guides

* Before sending: [verify a domain](/docs/domains) and [create a send-only key](/docs/send-api/credentials).
* Other ways to send: [REST API](/docs/send-api/send-email), [SMTP](/docs/smtp), [CLI quickstart](/docs/cli/quickstart), [Node.js SDK](/docs/guides/nodejs-sdk) and [Go SDK](/docs/guides/go-sdk).
* Request rules: [API authentication](/docs/api-reference/authentication), [scopes](/docs/api-reference/scopes), [idempotency](/docs/api-reference/idempotency), [errors](/docs/api-reference/errors) and [rate limits](/docs/api-reference/rate-limits).
* Testing and events: [sandbox mode](/docs/send-api/sandbox), [CLI webhook testing](/docs/cli/webhook-testing), [event payloads](/docs/api-reference/webhooks), [signature verification](/docs/api-reference/webhooks/security) and [delivery retries](/docs/api-reference/webhooks/retry-policy).
* Data and limits: [retention](/docs/retention), [tracking](/docs/tracking/open-tracking) and [plans and feature availability](/docs/facts#plans-and-features).
