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 with a verified sending domain
- An API key with the domain-scoped
messages:send:{your-domain}scope matching the domain inFrom.Email(usemessages:send:allonly 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 (
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:Install the SDK
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:
host.json
2
One function.json per function
Each folder name becomes the function name and its default route (The webhook endpoint is
/api/SendWelcome, /api/AhaSendWebhook):SendWelcome/function.json
AhaSendWebhook/function.json
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.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:local.settings.json:
local.settings.json
local.settings.json contains secrets, so add it to .gitignore.
Write the Azure Functions Handler
The binary is an ordinarynet/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.
main.go
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’sParseRequest 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:
webhook.go
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: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:
Recipientsaccepts 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
CreateMessagecall, recipient list included. Adding a recipient changes the body, so the same key then returns422rather than sending to the newcomer. - Other platforms: deploying on Google Cloud instead? See the Cloud Run functions guide; for long-running servers, start with Gin or chi.
- Attachments: add
AttachmentswithData,ContentType, andFileName. SetBase64: truefor binary files such as PDFs.
DomainsAPI, SuppressionsAPI, StatisticsAPI, and more).
Troubleshooting
Webhook verification always fails, or JSON decoding sees a strange envelope
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.Functions host can't start the handler
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.401 from the AhaSend API
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.401 calling the send endpoint
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>.400 error mentioning the from address
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.
