net/http, so a handler never sees an *http.Request: pass the Fiber context to the SDK, and verify webhooks with verifier.Parse instead of verifier.ParseRequest.
Prerequisites
- An AhaSend account with a verified sending domain
- An API key with the
messages:send:{domain}scope for your sending domain (ormessages:send:allif it must cover multiple 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
ReadTimeout, WriteTimeout, and IdleTimeout unset by default, so a listener that accepts a 30 MB body will also hold a connection open indefinitely while a client dribbles that body in. Set all three, and size ReadTimeout against the largest body you actually accept: a legitimate multi-megabyte delivery that cannot finish arriving inside the window is recorded as a failed webhook delivery. Put the listener behind a TLS-terminating reverse proxy, and cap request rate and concurrency there too: BodyLimit bounds one request, not the memory a burst of maximum-size deliveries can consume at once. The limiter keys on c.IP(), which is the proxy’s address unless you configure TrustProxy and ProxyHeader.
Send an Email from a Fiber Handler
Fiber’sCtx satisfies context.Context, but as a context that never cancels — it is pooled and reused, so Done() is always nil. Pass it to the SDK while the handler is running and derive an explicit timeout: the SDK’s default HTTP client caps each attempt at 30 seconds, but it retries up to three times with exponential backoff, so only your deadline bounds the call as a whole. The 30 seconds below buys one full-length attempt, or several fast failures and their backoff; raise it if you want both to fit.
send.go
Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>, and never ship that token to a browser. The check 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. For a user-facing endpoint, replace the token with your application’s authentication and authorization and load the recipient from your own user record instead of the request body: an endpoint that mails an arbitrary address on request is an open relay. Serve it only over HTTPS.
The example defaults to sandbox mode, which validates the request and fires the matching webhooks without delivering anything. See Sandbox Mode.
A successful create is multi-status: response.Data carries one result per recipient with Status of queued, scheduled, or error, so a 2xx can still mean nothing was accepted. Count the outcomes and fail the request when none queued.
The SDK reuses one idempotency key across its own retries of network failures, 429, and 5xx, but generates a fresh key per call, so it protects only that call — 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. Keep each event_id bound to the same message data: the API hashes the method, path, and body behind the key and answers a mismatch with 422. The mode prefix follows from that same rule: Sandbox is part of the hashed body, so reusing one key across both modes answers 422 rather than replaying, and the prefix gives each mode its own keys. 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
The SDK’sParseRequest takes a *http.Request, which Fiber never constructs. Use verifier.Parse instead: hand it the exact bytes from c.BodyRaw() and an http.Header built from the three signature headers AhaSend sends. Do not use c.Body(), because it can decompress an encoded request and change the signed bytes.
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 200 promptly after that durable handoff, because unsuccessful webhook requests are retried: six times over 16 minutes, and after 100 consecutive failures the webhook is disabled. Fiber reuses request buffers, so copy any c.BodyRaw() or c.Get() value that must outlive the handler — strings.Clone on the webhook-id you persist, for instance. Parsed event structs contain decoded copies. 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 message lists and statistics. - Rate limits: local pacing is per client instance. If you tune
ahasendClient.SetSendMessageRateLimit(requestsPerSecond, burstCapacity), keep it at or below the send limit assigned to your account and coordinate aggregate traffic across replicas. - Other frameworks: prefer
net/http-style routing? The same SDK patterns work in Gin and Echo. - Attachments: for binary files such as PDFs, base64-encode the bytes yourself, use the encoded string as
Data, and setBase64: true. The flag 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
Confirm the secret matches the dashboard exactly, including the
aha-whsec- prefix, and pass c.BodyRaw() directly to verifier.Parse: the signature is computed over the exact raw bytes AhaSend sent. Do not use c.Body() or body-mutating middleware on this route.413 on webhook deliveries
413 on webhook deliveries
BodyLimit is server-wide in Fiber, so the value that suits your JSON send route also caps webhooks. A route (inbound message) webhook carries the whole email with base64 attachments, and fasthttp rejects an oversized body before your handler runs, which AhaSend sees as a failed delivery. Size BodyLimit for a full message and cap the send route separately in your own middleware.Corrupted data after the handler returns
Corrupted data after the handler returns
Fiber is zero-copy by default: byte slices from
c.BodyRaw() and strings from c.Get() point into buffers that are recycled for the next request. If you store them, make copies (or enable fiber.Config{Immutable: true}, at a performance cost). The parsed event structs from the verifier are safe: they’re decoded copies.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.
