net/http handlers, so AhaSend’s webhook verifier accepts the *http.Request your handler receives. Middleware registered with Router.Use runs in registration order and must leave the signed webhook body untouched.
gorilla/mux is stable but no longer actively developed: v1.8.1 (October 2023) is still the current release. Nothing below depends on new router features, and the same handlers port to net/http.ServeMux or another router unchanged.
Prerequisites
- An AhaSend account with a verified sending domain
- An API key with the domain-specific
messages:send:{your-domain}scope, and your account ID
Install the SDK
google/uuid is a transitive dependency of the SDK, but the account ID is a uuid.UUID, so your own code imports it directly and it needs its own go get.
Configure Environment Variables
sandbox or live delivery mode and refuses to start for any other value.
Create the Client
Create the client once at startup and share it across handlers: it maintains its own rate-limit, retry, and idempotency state. Use anhttp.Server with explicit limits and timeouts to serve the router:
main.go
Send an Email from a gorilla/mux Handler
gorilla/mux handlers are ordinarynet/http handlers, so decode the JSON body with encoding/json:
send.go
2xx from the API is not a per-recipient guarantee: every entry in response.Data carries its own queued, scheduled, or error status, so inspect them all before reporting success. On the error path, apiErr.IsRetryable() separates the transient failures — 429, 5xx, network, and the 409 returned while an earlier request with the same key is still in flight — from terminal ones such as a validation error, a missing scope, or a key reused with a different body, which is why only the former is reported to the caller as a gateway error.
This example uses a dedicated bearer token for a server-to-server endpoint. For a user-facing route, use your application’s session authentication and authorization and load the recipient from server-authoritative storage instead of accepting an email address from the browser. Never expose either bearer token to client-side code or logs. Serve the route only over HTTPS, and apply per-caller request-rate and concurrency limits at your reverse proxy: an endpoint that sends to a caller-supplied address is a mail relay for anyone holding the token.
The explicit idempotency key protects a retry of the same business event outside the SDK’s internal retry loop. Reuse it only for the exact same payload and never log it — the API matches a key against the request body, so the same key with a changed body is rejected with 422 rather than replayed, which is why the key carries the delivery mode. Stored outcomes replay for 24 hours, but a 5xx is not stored and the key is released, so a retry after a server error can still send twice; reconcile an uncertain result before issuing another send when duplicates are unacceptable.
Handle Webhooks
gorilla/mux hands your handler the*http.Request, so the SDK’s ParseRequest can verify the HMAC signature over the exact raw body and return a typed event. Timestamp validation rejects stale signatures but does not deduplicate a valid delivery replayed inside the tolerance window.
webhook.go
registerAhaSendWebhook during startup with your durable store. Do not start the server if registration returns an error.
Create the webhook in your AhaSend dashboard pointing at https://your-app.com/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix).
maxWebhookBodyBytes bounds one request, not the process, and it is sized for inbound route.message deliveries, which embed the received email’s attachments. Lower it if this endpoint only receives message, suppression, and domain events — the body is buffered and copied again to build the signed string before the signature is checked, so the cap sets how much memory an unauthenticated caller can make each in-flight request hold.
EnqueueOnce must atomically store the verified webhook-id and durable work/outbox record, retaining the ID for at least the delivery and retry horizon. A duplicate receives 2xx without running the work again, while a storage failure receives 5xx so it can be retried. Process queued work idempotently outside the request; an untracked goroutine can be lost when the process exits. Apply request-rate and concurrency limits at the reverse proxy, and never log the raw body, signature, whole event/error, subjects, addresses, or message content.
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. - Rate limits: local pacing is per client instance. If you tune
SetSendMessageRateLimit, keep it at or below the send limit assigned to your account and coordinate aggregate traffic across replicas. - Other frameworks: the same SDK patterns work in chi, Gin, and Echo.
- Attachments: add
AttachmentswithData,ContentType, andFileName. SetBase64: truefor binary files such as PDFs.
DomainsAPI, SuppressionsAPI, StatisticsAPI, and more).
Troubleshooting
401 or 403 from the API
401 or 403 from the API
A
401 means the API key is missing, malformed, or revoked; a 403 means the key is valid but does not carry the sending domain’s messages:send scope. Both reach the same handler branch. Verify AHASEND_API_KEY is set in the process environment and review the API credentials guide without printing the key.Webhook verification always fails
Webhook verification always fails
Something consumed the request body before
ParseRequest ran. Check r.Use(...) middleware for body readers. Also confirm the secret matches the dashboard exactly, including the aha-whsec- prefix.405 Method Not Allowed on my routes
405 Method Not Allowed on my routes
.Methods(http.MethodPost) restricts the route to POST only. A request that matches the path with any other method is answered by gorilla/mux’s built-in 405 handler, which runs without your Router.Use middleware. Make sure your test request uses POST.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.
