Skip to main content
This guide shows how to use AhaSend to send transactional email from Gin in Go, keep credentials on the server, and test a sandbox send. A Gin handler wraps a standard *http.Request inside *gin.Context, so unwrap it: c.Request for verifier.ParseRequest. Body-binding or body-logging middleware must not read the webhook body first; Gin’s built-in request logger does not read it. 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 1.25 or newer, which Gin 1.12 requires
  • An AhaSend account with a verified sending domain
  • An API key with the domain-scoped messages:send:{yourdomain.com} permission, 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 with sandbox: 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
Run 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. 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

Use your deployment platform’s secret manager rather than committing these values. The example protects the send route with HTTP Basic authentication for a concrete service-to-service boundary; serve it only over HTTPS. A user-facing application should use its existing session or token middleware and load the recipient from the authenticated user’s server-side record. Authentication bounds who can send, not how much: add per-caller rate limiting to the group as well — a golang.org/x/time/rate limiter keyed by caller is enough — so one leaked credential or one looping client cannot drain the account.

Create the Client

Create the client once at startup and share it across handlers so its transport and rate limiter are reused. Automatic idempotency keys are still generated per request:
main.go
The SDK’s outbound timeout, retry count, and the handler’s end-to-end context deadline fit within the server’s write and shutdown budgets. Keep those budgets aligned if you change any of them. SetTrustedProxies(nil) ignores client-supplied forwarding headers. If a load balancer or reverse proxy terminates HTTPS, replace nil with only that proxy’s IP addresses or CIDRs. SetMode(gin.ReleaseMode) matters for more than log volume: Gin defaults to debug mode, and in debug mode a recovered panic writes the request’s entire header block to the error log — cookies, webhook-signature, and everything else except Authorization.

Send an Email from a Gin Handler

send.go
The send API is multi-status: a 202 carries one Data entry per recipient, and an entry can report Status == "error" with a nil ID even though the SDK call itself returned no error. This handler sends to one recipient and checks the single entry it expects; loop over every entry once you extend Recipients. Split the failures the same way the handler does: a rejected payload, a revoked key, or a missing scope fails identically on every attempt, so surface it as a 500 and page someone, while a rate limit or an upstream 5xx is worth retrying — the rate-limit branch passes AhaSend’s own Retry-After back to the caller. The SDK automatically retries transient failures and attaches an automatic idempotency key to each send. This handler overrides it with a stable key derived from the immutable signup ID so a later retry of the same business operation can reuse the key. Reuse a key only with the exact same payload: the API rejects the same key carrying a changed body with 422, which the SDK reports as ErrorTypeIdempotency and the handler turns into a 409 rather than a retryable 502. A 409 from the API is the separate in-progress case — an earlier request with that key has not finished — which the SDK reports as ErrorTypeIdempotencyConflict with the remaining seconds in RetryAfter. Stored outcomes replay for 24 hours, but a 5xx is not stored, so a retry after a server error can still send twice; persist workflow state and make uncertain results safe to reconcile.

Handle Webhooks

Gin handlers wrap a standard *http.Request, so the SDK’s ParseRequest works directly: it verifies the HMAC signature and timestamp, then returns a typed event. Timestamp validation is not replay deduplication. No body-parsing middleware may run on this route first.
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). This minimal receiver deliberately verifies and acknowledges without performing a business side effect. Before adding one, atomically commit the verified webhook-id header together with durable queue/outbox work; acknowledge an already-committed ID with 2xx, and process durable work idempotently. Do not launch a bare goroutine: it can be lost when the process exits, and *gin.Context is recycled once the handler returns, so anything that outlives the request must carry c.Copy() instead of c. Keep reverse-proxy request-size and concurrency limits at least as strict as the application limit above, return promptly, and keep failures opaque.

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

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 for logging or body-buffering middleware on the webhook route. Also confirm the secret matches the dashboard exactly, including the aha-whsec- prefix.
The From address must belong to a verified sending domain on your account. Check domain status in the dashboard.