net/http, so the SDK and its webhook verifier work without an adapter.
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
Install the SDK
Configure Environment Variables
Create the Client
Create the client once at startup and share it across handlers: it maintains its own rate-limit, retry, and idempotency state:main.go
middleware.Logger and middleware.Recoverer are safe on every route: neither touches the request body, and Logger records only request metadata. Just don’t add middleware that reads the request body ahead of the webhook route (more on that below).
Send an Email from a chi Handler
chi handlers are ordinarynet/http handlers, so decode the JSON body with encoding/json:
send.go
Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>; never ship that token to a browser. The comparison uses subtle.ConstantTimeCompare because Go’s == on strings returns as soon as two bytes differ, which leaks the token prefix to an attacker who can time repeated requests. If the endpoint is user-facing, replace the check with your application’s authentication and authorization and load the recipient address from your own user record instead of the request body — an endpoint that mails an arbitrary address with arbitrary content is an open relay. Add per-caller rate limiting too; go-chi/httprate plugs in as chi middleware.
The example defaults to sandbox mode, which validates the request and fires the matching webhooks without delivering anything. See Sandbox Mode.
The send API is multi-status: a 202 response has one Data entry per recipient, and an entry can carry Status == "error" with a nil ID even though the SDK call returned no error. Inspect every entry, as the handler does above, rather than treating HTTP-level success as proof that every message was queued.
The SDK retries network failures, 429, and 5xx responses with exponential backoff, reusing one idempotency key across those attempts. When you supply no key it generates a fresh random one per call, so it protects only that call’s internal retries — a second HTTP request to this route would get a new key and send a second email. That is why the example derives a stable key from the caller’s event_id and passes it with api.WithIdempotencyKey. Keep each event_id bound to the same message data: the API matches a key against the method, path, and a hash of the body, and answers a mismatch with 422. The mode prefix keeps a sandbox result from being replayed for a live send, since Sandbox is part of that body. Stored outcomes replay for 24 hours, and a 5xx is never stored, so even a same-key retry after a server error can still send twice.
Handle Webhooks
chi hands you the raw*http.Request, so the SDK’s ParseRequest works directly: it verifies the HMAC signature, rejects timestamps outside its tolerance window, and returns a typed event. ParseRequest itself does not limit the body, so bound the stream before calling it.
webhook.go
https://your-app.com/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix).
Return 2xx promptly after durable work has been accepted. Do slow processing in a durable queue, not an untracked goroutine: if enqueueing fails, return 5xx so AhaSend can retry.
maxWebhookBodyBytes caps one request, not the process. ParseRequest buffers the whole body and copies it again to build the signed string, so cap concurrency and request rate at the reverse proxy as well, or enough simultaneous maximum-size deliveries will exhaust memory. As you replace the log.Print calls with real handling, keep the raw body, the signature header, and the event object itself out of your logs: those carry recipient addresses and 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: sending at volume? Tune the client with
ahasendClient.SetSendMessageRateLimit(500, 1000). - Other frameworks: the same SDK patterns work in Gin, Echo, and gorilla/mux.
- Attachments: every entry needs
Data,ContentType, andFileName. For binary files such as PDFs, encode the bytes withbase64.StdEncoding.EncodeToString(data), use that string asData, and setBase64: true. The flag tells AhaSend how to decodeData; it does not perform the encoding.
DomainsAPI, SuppressionsAPI, StatisticsAPI, and more).
Troubleshooting
401 from the API
401 from the API
The API key is missing, malformed, or revoked. Verify
AHASEND_API_KEY is set in the process environment and that the key exists in your dashboard.Webhook verification always fails
Webhook verification always fails
Something consumed the request body before
ParseRequest ran. Check r.Use(...) chains and any middleware mounted on a parent router for body readers. middleware.Logger and middleware.Recoverer are fine; anything that buffers or dumps the body is not. Also confirm the secret matches the dashboard exactly, including the aha-whsec- prefix.405 or 404 on my routes
405 or 404 on my routes
chi matches methods exactly, so any method other than POST on
/api/welcome returns 405. A trailing slash is a different failure: chi treats /webhooks/ahasend/ as a distinct path from /webhooks/ahasend and returns 404 for it, which AhaSend counts as a failed delivery and retries. Register the webhook URL without the trailing slash, or add middleware.StripSlashes.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.
