net/http, so the SDK and its webhook verifier work without an adapter.
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. See send-only credentials and authentication.
Keep credentials on the server. Use sandbox mode 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 and retry policy.
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
Try a Sandbox Send
Run this short SDK check locally before adding the longer server integration below. Use a verified sender, a send-only API key, and your account ID. It sends to a reserved test address withsandbox: 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:
quick-send.go
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.
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
For substitutions, attachments and scheduled sends, use the sending guide. The Node.js SDK and Go SDK references explain client options, per-recipient results and retries. Use idempotency rules when your application retries a business operation.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.Related Guides
- Before sending: verify a domain and create a send-only key.
- Other ways to send: REST API, SMTP, CLI quickstart, Node.js SDK and Go SDK.
- Request rules: API authentication, scopes, idempotency, errors and rate limits.
- Testing and events: sandbox mode, CLI webhook testing, event payloads, signature verification and delivery retries.
- Data and limits: retention, tracking and plans and feature availability.

