Skip to main content
chi handlers are plain 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 in From.Email (use messages:send:all only 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
In production, put this HTTP listener behind a TLS-terminating reverse proxy or load balancer. Do not send the bearer endpoint token over plaintext internet traffic. 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 ordinary net/http handlers, so decode the JSON body with encoding/json:
send.go
Call this route only from trusted server-side code with 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.
ParseRequest consumes the request body, and it must see the body untouched. Don’t attach any body-reading middleware (request dumpers, body-buffering loggers, custom decompression) to the webhook route.
webhook.go
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).
Timestamp verification is not replay deduplication: the same valid delivery can be presented again inside the tolerance window. Before adding side effects, atomically persist the verified webhook-id header together with durable queue/outbox work. Acknowledge an already-recorded ID without processing it again, and make the worker idempotent.
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: 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.
  • 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, and FileName. For binary files such as PDFs, encode the bytes with base64.StdEncoding.EncodeToString(data), use that string as Data, and set Base64: true. The flag tells AhaSend how to decode Data; it does not perform the encoding.
See the API reference for every service the SDK exposes (DomainsAPI, SuppressionsAPI, StatisticsAPI, and more).

Troubleshooting

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.
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.
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.
The From address must belong to a verified sending domain on your account. Check domain status in the dashboard.