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

# 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](/docs/api-reference/scopes). See [send-only credentials](/docs/send-api/credentials) and [authentication](/docs/api-reference/authentication).

Keep credentials on the server. Use [sandbox mode](/docs/send-api/sandbox) 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](/docs/api-reference/webhooks/security) and [retry policy](/docs/api-reference/webhooks/retry-policy).

## 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](/docs/domains), a [send-only API key](/docs/send-api/credentials), 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

<Steps>
  <Step title="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.
    </Warning>
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

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](/docs/send-api/send-email). The [Node.js SDK](/docs/guides/nodejs-sdk) and [Go SDK](/docs/guides/go-sdk) references explain client options, per-recipient results and retries. Use [idempotency rules](/docs/api-reference/idempotency) when your application retries a business operation.

## Troubleshooting

<AccordionGroup>
  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

Other language guides: [Azure Functions with Node.js](/docs/guides/azure-functions-nodejs) and [Azure Functions with Python](/docs/guides/azure-functions-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).


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