> ## 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. 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.

# How to Send Email from Azure Functions (Go)

> Send transactional email from Azure Functions in Go with a custom handler and the AhaSend Go SDK, plus HMAC-verified delivery webhooks and a Linux deploy.

This guide shows how to use AhaSend to send transactional email from Azure Functions in Go, keep credentials on the server, and test a sandbox send.

This guide runs Go as an Azure Functions **custom handler**: a small HTTP server the Functions host forwards requests to. Ordinary `net/http` code works, including the SDK's `verifier.ParseRequest`. As of September 2026, Microsoft documents first-class Go support as a public preview and recommends it for new Go function apps. Check the [Azure custom-handler documentation](https://learn.microsoft.com/en-us/azure/azure-functions/functions-custom-handlers) and its linked Go preview requirements before choosing a hosting plan.

Use a **send-only key** from **Credentials → Add → API Key v2** with `messages:send:{your-domain}`. Use `messages:send:all` when sending from many domains. For management tasks such as creating a webhook, use a separate **full key** from **Account Settings → API Keys** with the needed [scopes](https://ahasend.com/docs/api-reference/scopes.md). See [send-only credentials](https://ahasend.com/docs/send-api/credentials.md) and [authentication](https://ahasend.com/docs/api-reference/authentication.md).

Keep credentials on the server. Use [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) for tests. Reuse a stable, business-event idempotency key only with the exact same payload; use different keys for sandbox and live sends. Check every recipient's status and return a failure when any item is `error`; HTTP 202 alone does not mean every recipient was accepted.

For webhooks, verify the raw request bytes using the full secret as the raw UTF-8 HMAC key, including its `aha-whsec-` or `aha-rsec-` prefix. Never base64-decode the secret. The AhaSend SDK verifiers handle this. These examples use a 30,000,000-byte webhook body limit; a hosting platform may impose a smaller limit. Acknowledge verified unknown event types with 2xx. See [verification](https://ahasend.com/docs/api-reference/webhooks/security.md) and [retry policy](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

## Prerequisites

* Go installed
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) with the domain-scoped `messages:send:{your-domain}` scope matching the domain in `From.Email` (use `messages:send:all` only if the key must cover several domains), and your account ID
* A Linux/x64 Azure Function App configured for a custom handler (select .NET as the runtime stack when creating it), the [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (`func`), and the Azure CLI (`az`)

## Project Layout

A custom handler app pairs one Go binary with the standard Functions metadata files, one folder per function:

```text theme={null}
.
├── host.json
├── local.settings.json # local dev only, never commit
├── SendWelcome/
│ └── function.json
├── AhaSendWebhook/
│ └── function.json
├── main.go
├── webhook.go
└── handler # compiled Go binary
```

## Install the SDK

```bash theme={null}
go mod init example.com/yourapp
go get github.com/AhaSend/ahasend-go
go get github.com/google/uuid
```

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](https://ahasend.com/docs/domains.md), a [send-only API key](https://ahasend.com/docs/send-api/credentials.md), and your account ID. It sends to a reserved test address with `sandbox: true`, so no email is delivered.

In your terminal, set `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_FROM` (an address on your verified domain) as environment variables. Go does not load `.env` files by itself. Keep the key in your local secret store and out of source control.

Save this standalone check as `quick-send.go` in the module directory where you ran `go get`:

```go quick-send.go theme={null}
package main
import (
  "context"
  "os"
  "github.com/AhaSend/ahasend-go/api"
  "github.com/AhaSend/ahasend-go/models/common"
  "github.com/AhaSend/ahasend-go/models/requests"
  "github.com/google/uuid"
)
func main() {
  client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
  text, sandbox := "Hello from AhaSend", true
  result, response, err := client.MessagesAPI.CreateMessage(context.Background(), uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID")), requests.CreateMessageRequest{
    From: common.SenderAddress{Email: os.Getenv("AHASEND_FROM")}, Recipients: []common.Recipient{{Email: "recipient@example.com"}},
    Subject: "My first sandbox email", TextContent: &text, Sandbox: &sandbox,
  })
  if err != nil { panic("Sandbox request failed; check credentials, sender and account") }
  if len(result.Data) == 0 { panic("No recipient results") }
  for _, item := range result.Data { println("HTTP", response.StatusCode, "recipient status", item.Status); if item.Status != "queued" && item.Status != "scheduled" { panic("Recipient rejected") } }
}
```

Run `go run quick-send.go`. It prints the HTTP status and each recipient status and exits with a failure on a request error or rejected recipient. This checks the SDK locally; it does not deploy a cloud function. Delete `quick-send.go` after the check so its `main` function does not conflict with the full app below.

`google/uuid` arrives with the SDK as a transitive dependency, but the account ID is a `uuid.UUID` that your own code parses, so it needs its own `go get` or the build fails on a missing `go.sum` entry.

## Configure the Functions Host

**1. host.json**

Point the host at your compiled binary and enable raw HTTP pass-through:

```json host.json theme={null}
{
  "version": "2.0",
  "customHandler": {
    "description": {
      "defaultExecutablePath": "handler"
    },
    "enableProxyingHttpRequest": true
  }
}
```

> [!WARNING]
>
> `enableProxyingHttpRequest: true` is what makes this guide work. Without it, the host wraps every request in a JSON envelope instead of passing the raw HTTP request through: plain JSON decoding and webhook signature verification both break. Older custom-handler samples use `enableForwardingHttpRequest` for the same purpose; the host still honours it, but `enableProxyingHttpRequest` is the setting Microsoft documents today.

**2. One function.json per function**

Each folder name becomes the function name and its default route (`/api/SendWelcome`, `/api/AhaSendWebhook`):

```json SendWelcome/function.json theme={null}
{
  "bindings": [
    {
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["post"],
      "authLevel": "function"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ]
}
```

```json AhaSendWebhook/function.json theme={null}
{
  "bindings": [
    {
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["post"],
      "authLevel": "anonymous"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ]
}
```

The webhook endpoint is `anonymous` because the HMAC signature, not a function key, is what authenticates a delivery — the verifier rejects anything it can't attribute to your secret. You can add a key on top by switching this function to `authLevel: "function"` and registering the webhook URL with `?code=<function-key>` appended, but then rotating that key silently breaks deliveries, and AhaSend disables a webhook after more than 100 consecutive failed attempts.

## Configure App Settings

Azure Function App settings are exposed to your binary as environment variables. Set them in the Portal under **Function App → Environment variables**, or with the CLI:

```bash theme={null}
az functionapp config appsettings set \
  --name your-function-app --resource-group your-rg \
  --settings FUNCTIONS_WORKER_RUNTIME=custom \
  AHASEND_API_KEY=aha-sk-... \
  AHASEND_ACCOUNT_ID=your-account-uuid \
  AHASEND_WEBHOOK_SECRET=aha-whsec-...
```

For local development, put the same values in `local.settings.json`:

```json local.settings.json theme={null}
{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "custom",
    "AHASEND_API_KEY": "aha-sk-...",
    "AHASEND_ACCOUNT_ID": "your-account-uuid",
    "AHASEND_WEBHOOK_SECRET": "aha-whsec-..."
  }
}
```

`local.settings.json` contains secrets, so add it to `.gitignore`.

## Write the Azure Functions Handler

The binary is an ordinary `net/http` server. The only Azure-specific detail is the listen port: the host tells you where to listen via `FUNCTIONS_CUSTOMHANDLER_PORT`. Create the SDK client and webhook verifier once at startup and reuse them across invocations.

```go main.go theme={null}
package main

import (
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"net/mail"
	"os"

	"github.com/AhaSend/ahasend-go"
	"github.com/AhaSend/ahasend-go/api"
	"github.com/AhaSend/ahasend-go/models/common"
	"github.com/AhaSend/ahasend-go/models/requests"
	"github.com/AhaSend/ahasend-go/webhooks"
	"github.com/google/uuid"
)

var (
	ahasendClient *api.APIClient
	accountID     uuid.UUID
	verifier      *webhooks.WebhookVerifier
)

func main() {
	var err error
	accountID, err = uuid.Parse(os.Getenv("AHASEND_ACCOUNT_ID"))
	if err != nil {
		log.Fatalf("invalid AHASEND_ACCOUNT_ID: %v", err)
	}
	apiKey := os.Getenv("AHASEND_API_KEY")
	if apiKey == "" {
		log.Fatal("AHASEND_API_KEY is required")
	}
	webhookSecret := os.Getenv("AHASEND_WEBHOOK_SECRET")
	if webhookSecret == "" {
		log.Fatal("AHASEND_WEBHOOK_SECRET is required")
	}
	ahasendClient = api.NewAPIClient(api.WithAPIKey(apiKey))
	verifier, err = webhooks.NewWebhookVerifier(webhookSecret)
	if err != nil {
		log.Fatalf("failed to create webhook verifier: %v", err)
	}

	port := os.Getenv("FUNCTIONS_CUSTOMHANDLER_PORT")
	if port == "" {
		port = "8080" // running outside the Functions host
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/api/SendWelcome", sendWelcome)
	mux.HandleFunc("/api/AhaSendWebhook", handleAhaSendWebhook)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}

const maxSendBodyBytes = 64 << 10

type welcomeInput struct {
	Email          string `json:"email"`
	Name           string `json:"name"`
	IdempotencyKey string `json:"idempotency_key"`
}

func sendWelcome(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxSendBodyBytes)

	var in welcomeInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "request body must be JSON", http.StatusBadRequest)
		return
	}

	// Reject anything that isn't a bare address, so a display name can't be
	// smuggled in through the email field.
	addr, err := mail.ParseAddress(in.Email)
	if err != nil || addr.Address != in.Email {
		http.Error(w, "email must be a bare address such as user@example.com", http.StatusBadRequest)
		return
	}
	if in.IdempotencyKey == "" || len(in.IdempotencyKey) > 255 {
		http.Error(w, "idempotency_key is required and must be at most 255 characters", http.StatusBadRequest)
		return
	}
	if len(in.Name) > 100 {
		http.Error(w, "name must be at most 100 characters", http.StatusBadRequest)
		return
	}

	message := requests.CreateMessageRequest{
		Sandbox: ahasend.Bool(true),
		From:        common.SenderAddress{Email: "hello@yourdomain.com", Name: ahasend.String("Your App")},
		Recipients:  []common.Recipient{{Email: in.Email, Name: ahasend.String(in.Name)}},
		Subject:     "Welcome to Your App!",
		HtmlContent: ahasend.String("<h1>Welcome aboard 🎉</h1><p>We're glad you're here.</p>"),
		TextContent: ahasend.String("Welcome aboard! We're glad you're here."),
	}

	response, _, err := ahasendClient.MessagesAPI.CreateMessage(
		r.Context(), accountID, message,
		api.WithIdempotencyKey(in.IdempotencyKey),
	)
	if err != nil {
		var apiErr *api.APIError
		if errors.As(err, &apiErr) {
			// Log identifiers only. The raw response body echoes the
			// recipient address and belongs nowhere near your logs.
			log.Printf("AhaSend send failed: status=%d type=%s request_id=%s",
				apiErr.StatusCode, apiErr.Type, apiErr.RequestID)
			http.Error(w, "failed to send email", http.StatusBadGateway)
			return
		}
		log.Print("AhaSend send failed before a response arrived")
		http.Error(w, "unexpected error", http.StatusInternalServerError)
		return
	}

	// A 202 reports one result per recipient, so success is per entry: a
	// rejected recipient comes back with status "error" and a nil ID.
	if len(response.Data) == 0 {
		log.Print("AhaSend returned no per-recipient result")
		http.Error(w, "failed to send email", http.StatusBadGateway)
		return
	}
	result := response.Data[0]
	if result.Status == "error" || result.ID == nil {
		log.Printf("AhaSend rejected the recipient: status=%s", result.Status)
		http.Error(w, "failed to send email", http.StatusBadGateway)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(map[string]string{"message_id": *result.ID}); err != nil {
		log.Printf("failed to write response: %v", err)
	}
}
```

> [!WARNING]
>
> This function mails whatever address its caller supplies, so the function key is the only thing standing between it and an open relay. Keep `authLevel: "function"`, treat the key like a password, and rotate it if it leaks. In production, prefer looking the recipient up in your own datastore from an internal identifier over accepting an address on the wire — Azure Functions applies no per-caller rate limit of its own, so a leaked key is a spam faucet until you revoke it.

The caller owns the idempotency key, which is what makes a retry safe. Reusing a key with the same body inside 24 hours replays the stored result instead of sending again; reusing it with a *different* body returns `422`, so derive the key from the thing you're sending (`welcome-<user-id>`, say) rather than from the attempt. Left to itself the SDK generates a fresh random key per call, which covers only its own internal retries — of `429`, `5xx`, and network failures, with exponential backoff — and does nothing about a caller that retries. Note that a `5xx` outcome is never stored, so a retry after a server error can still send twice.

## Handle Webhooks

Because the host passes the original HTTP request through untouched, the SDK's `ParseRequest` works directly on it: it verifies the HMAC signature over the raw body bytes, rejects timestamps outside its five-minute tolerance in either direction, and returns a typed event:

```go webhook.go theme={null}
package main

import (
	"errors"
	"log"
	"net/http"

	"github.com/AhaSend/ahasend-go/webhooks"
)

// Delivery-status payloads are a few KB. Keep the cap tight: this endpoint is
// anonymous, and the body is buffered before the signature can be checked.
const maxWebhookBodyBytes = 30_000_000

func handleAhaSendWebhook(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
	event, err := verifier.ParseRequest(r)
	if err != nil {
		var tooLarge *http.MaxBytesError
		switch {
		case errors.As(err, &tooLarge):
			w.WriteHeader(http.StatusRequestEntityTooLarge)
		case errors.Is(err, webhooks.ErrMissingHeaders),
			errors.Is(err, webhooks.ErrInvalidSignature),
			errors.Is(err, webhooks.ErrExpiredTimestamp):
			w.WriteHeader(http.StatusUnauthorized)
		case errors.Is(err, webhooks.ErrUnknownEventType):
			// The signature was verified before the type was classified, so this
			// is a genuine event this SDK version doesn't know yet. Acknowledge
			// it: AhaSend disables a webhook after more than 100 consecutive failed attempts.
			w.WriteHeader(http.StatusOK)
		default:
			w.WriteHeader(http.StatusBadRequest)
		}
		return
	}

	// Log the message ID, not the recipient: Application Insights is not the
	// place to accumulate your users' email addresses.
	switch e := event.(type) {
	case *webhooks.MessageDeliveredEvent:
		log.Printf("delivered: message=%s", e.Data.ID)
	case *webhooks.MessageBouncedEvent:
		log.Printf("bounced: message=%s", e.Data.ID)
	case *webhooks.MessageOpenedEvent:
		// Scanners and prefetchers trip open tracking constantly, so anything
		// you act on should exclude them.
		log.Printf("opened: message=%s bot=%t", e.Data.ID, e.Data.IsBot)
	default:
		log.Printf("unhandled event type: %s", event.GetType())
	}

	w.WriteHeader(http.StatusOK)
}
```

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-function-app.azurewebsites.net/api/AhaSendWebhook`, and copy its secret into the `AHASEND_WEBHOOK_SECRET` app setting exactly as shown (including the `aha-whsec-` prefix).

Timestamp checking is not complete replay protection: the same correctly signed delivery can be replayed inside the accepted window. Before acknowledging the webhook, atomically commit the `webhook-id` request header **together with** a durable work/outbox record, and ignore IDs already committed. Don't commit the ID separately: if the process fails before recording the work, a retry would look like a duplicate and the event would be lost.

Return 200 promptly because AhaSend retries webhooks that don't get a timely 2xx. For slow work, durably enqueue it before returning and process it with a queue-triggered function — which needs an `extensionBundle` entry in `host.json`, since custom handlers get non-HTTP triggers through extension bundles. Don't start a background goroutine and return: Azure Functions can shut down the instance after the invocation completes.

## Build and Deploy

The Linux/x64 Function App used in this guide needs a linux/amd64 binary, so cross-compile, then publish with Core Tools:

```bash theme={null}
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o handler
func azure functionapp publish your-function-app
```

`CGO_ENABLED=0` produces a static binary, so the handler doesn't depend on the C libraries that happen to be on your build machine.

For local testing, build for your own OS and run `func start`: the host reads `local.settings.json` and proxies to your binary. In CI, run the same two commands after `az login` (or use a deployment workflow such as the official Azure Functions GitHub Action).

## Going Further

For substitutions, attachments and scheduled sends, use the [sending guide](https://ahasend.com/docs/send-api/send-email.md). The [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md) references explain client options, per-recipient results and retries. Use [idempotency rules](https://ahasend.com/docs/api-reference/idempotency.md) when your application retries a business operation.

## Troubleshooting

> [accordion] Webhook verification always fails, or JSON decoding sees a strange envelope
>
> Check that `enableProxyingHttpRequest` is `true` in `host.json`. Without it the host sends your binary a JSON invocation envelope instead of the raw request, so the body your code sees is not what AhaSend signed and `ParseRequest` (and plain `json.Decode`) fail.

> [accordion] Functions host can't start the handler
>
> Verify `defaultExecutablePath` matches the binary name you deployed, that the binary was built with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` for a Linux plan, and that it listens on `FUNCTIONS_CUSTOMHANDLER_PORT`. Hardcoding a port makes the host time out waiting for the worker.

> [accordion] 401 from the AhaSend API
>
> The API key is missing, malformed, or revoked. Confirm the `AHASEND_API_KEY` app setting is present in the Function App environment (Portal → Environment variables). `local.settings.json` values are not deployed to Azure.

> [accordion] 401 calling the send endpoint
>
> `SendWelcome` uses `authLevel: "function"`, so requests need a function key: `https://your-function-app.azurewebsites.net/api/SendWelcome?code=<function-key>`.

> [accordion] 400 error mentioning the from address
>
> The `From` address must belong to a verified sending domain on your account. Check domain status in the dashboard.

Other language guides: [Azure Functions with Node.js](https://ahasend.com/docs/guides/azure-functions-nodejs.md) and [Azure Functions with Python](https://ahasend.com/docs/guides/azure-functions-python.md).

## Related Guides

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.