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

# How to Send Email with Azure Functions (Go)

> Send transactional email from Azure Functions in Go using custom handlers and the AhaSend email API, with HMAC-verified delivery webhooks.

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`. Azure also has first-class Go support in public preview, which Microsoft recommends for new Go function apps — but during preview it runs only on the Flex Consumption plan, whereas custom handlers are supported across every Azure Functions hosting option.

## 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/-/settings/api-keys) 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
```

`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 100 consecutive failures.
  </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{
		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 = 1 << 20

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 100 consecutive failures.
			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

* **Templating**: set `Substitutions: map[string]interface{}{"first_name": "Jane"}` and use `{{ first_name }}` in the subject or body.
* **Batch sends**: `Recipients` accepts up to 100 entries; each gets a separate message.
* **Tags**: set `Tags: []string{"welcome"}` to filter messages in list and statistics queries. Webhooks are scoped by domain, not by tag, and message events do not carry the tags.
* **Batch idempotency**: one key covers one whole `CreateMessage` call, recipient list included. Adding a recipient changes the body, so the same key then returns `422` rather than sending to the newcomer.
* **Other platforms**: deploying on Google Cloud instead? See the [Cloud Run functions guide](/docs/guides/google-cloud-functions); for long-running servers, start with [Gin](/docs/guides/gin) or [chi](/docs/guides/chi).
* **Attachments**: add `Attachments` with `Data`, `ContentType`, and `FileName`. Set `Base64: true` for binary files such as PDFs.

See the [API reference](/docs/api-reference) for every service the SDK exposes (`DomainsAPI`, `SuppressionsAPI`, `StatisticsAPI`, and more).

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