> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# How to Send Email with gorilla/mux (Go)

> Send transactional email from a gorilla/mux (Go) app with the AhaSend Go SDK: sandbox/live mode, idempotent retries, and deduplicated signed webhooks.

This guide shows how to use AhaSend to send transactional email from gorilla/mux in Go, keep credentials on the server, and test a sandbox send.

[gorilla/mux](https://github.com/gorilla/mux) routes plain `net/http` handlers, so AhaSend's webhook verifier accepts the `*http.Request` your handler receives. Middleware registered with `Router.Use` runs in registration order and must leave the signed webhook body untouched.

The examples use gorilla/mux v1.8.1. The same `net/http` handlers can also be mounted on `http.ServeMux` or another router.

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](https://ahasend.com/docs/api-reference/scopes.md). See [send-only credentials](https://ahasend.com/docs/send-api/credentials.md) and [authentication](https://ahasend.com/docs/api-reference/authentication.md).

Keep credentials on the server. Use [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) 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](https://ahasend.com/docs/api-reference/webhooks/security.md) and [retry policy](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

## Prerequisites

* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://ahasend.com/docs/send-api/credentials.md) with the domain-specific `messages:send:{your-domain}` scope, and your account ID

## Install the SDK

```bash theme={null}
go mod init example.com/ahasend-mux
go get github.com/AhaSend/ahasend-go
go get github.com/gorilla/mux
go get github.com/google/uuid
```

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](https://ahasend.com/docs/domains.md), a [send-only API key](https://ahasend.com/docs/send-api/credentials.md), 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`:

```go quick-send.go theme={null}
package main
import (
  "context"
  "os"
  "github.com/AhaSend/ahasend-go/api"
  "github.com/AhaSend/ahasend-go/models/common"
  "github.com/AhaSend/ahasend-go/models/requests"
  "github.com/google/uuid"
)
func main() {
  client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
  text, sandbox := "Hello from AhaSend", true
  result, response, err := client.MessagesAPI.CreateMessage(context.Background(), uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID")), requests.CreateMessageRequest{
    From: common.SenderAddress{Email: os.Getenv("AHASEND_FROM")}, Recipients: []common.Recipient{{Email: "recipient@example.com"}},
    Subject: "My first sandbox email", TextContent: &text, Sandbox: &sandbox,
  })
  if err != nil { panic("Sandbox request failed; check credentials, sender and account") }
  if len(result.Data) == 0 { panic("No recipient results") }
  for _, item := range result.Data { println("HTTP", response.StatusCode, "recipient status", item.Status); if item.Status != "queued" && item.Status != "scheduled" { panic("Recipient rejected") } }
}
```

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

```bash theme={null}
export AHASEND_API_KEY=aha-sk-...
export AHASEND_ACCOUNT_ID=your-account-uuid
export AHASEND_WEBHOOK_SECRET=aha-whsec-...
export AHASEND_DELIVERY_MODE=sandbox
export WELCOME_ENDPOINT_TOKEN=a-long-random-server-to-server-token
```

Keep the API key, webhook secret, and endpoint token in your deployment platform's secret store. The example requires an explicit `sandbox` or `live` delivery mode and refuses to start for any other value.

## Create the Client

Create the client once at startup and share it across handlers: it maintains its own rate-limit, retry, and idempotency state. Use an `http.Server` with explicit limits and timeouts to serve the router:

```go main.go theme={null}
package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/AhaSend/ahasend-go/api"
	"github.com/AhaSend/ahasend-go/webhooks"
	"github.com/google/uuid"
	"github.com/gorilla/mux"
)

var (
	ahasendClient *api.APIClient
	accountID     uuid.UUID
	sandboxMode   bool
	welcomeToken  string
)

func main() {
	apiKey := os.Getenv("AHASEND_API_KEY")
	if apiKey == "" {
		log.Fatal("AHASEND_API_KEY is required")
	}
	welcomeToken = os.Getenv("WELCOME_ENDPOINT_TOKEN")
	if welcomeToken == "" {
		log.Fatal("WELCOME_ENDPOINT_TOKEN is required")
	}

	var err error
	accountID, err = uuid.Parse(os.Getenv("AHASEND_ACCOUNT_ID"))
	if err != nil {
		log.Fatal("AHASEND_ACCOUNT_ID must be a UUID")
	}

	switch os.Getenv("AHASEND_DELIVERY_MODE") {
	case "sandbox":
		sandboxMode = true
	case "live":
		sandboxMode = false
	default:
		log.Fatal("AHASEND_DELIVERY_MODE must be sandbox or live")
	}

	ahasendClient = api.NewAPIClient(api.WithAPIKey(apiKey))

	r := mux.NewRouter()
	r.Handle("/api/welcome", requireWelcomeToken(http.HandlerFunc(sendWelcome))).Methods(http.MethodPost)
	// Demo store only: replace with durable storage before adding side effects.
	if err := registerAhaSendWebhook(r, &memoryWebhookStore{events: make(map[string]webhooks.WebhookEvent)}); err != nil {
		log.Fatal(err)
	}

	server := &http.Server{
		Addr:              ":8080",
		Handler:           r,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    1 << 20,
	}

	shutdownSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	go func() {
		<-shutdownSignal.Done()
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := server.Shutdown(shutdownCtx); err != nil {
			log.Print("HTTP server graceful shutdown failed")
		}
	}()

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal("HTTP server stopped unexpectedly")
	}
}
```

## Send an Email from a gorilla/mux Handler

gorilla/mux handlers are ordinary `net/http` handlers, so decode the JSON body with `encoding/json`:

```go send.go theme={null}
package main

import (
	"crypto/sha256"
	"crypto/subtle"
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"net/mail"
	"strings"

	"github.com/AhaSend/ahasend-go"
	"github.com/AhaSend/ahasend-go/api"
	"github.com/AhaSend/ahasend-go/models/common"
	"github.com/AhaSend/ahasend-go/models/requests"
	"github.com/google/uuid"
)

type welcomeInput struct {
	EventID string `json:"event_id"`
	Email   string `json:"email"`
	Name    string `json:"name"`
}

func requireWelcomeToken(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
		providedHash := sha256.Sum256([]byte(provided))
		expectedHash := sha256.Sum256([]byte(welcomeToken))
		if provided == "" || subtle.ConstantTimeCompare(providedHash[:], expectedHash[:]) != 1 {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func sendWelcome(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()

	var in welcomeInput
	if err := decoder.Decode(&in); err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			w.WriteHeader(http.StatusRequestEntityTooLarge)
		} else {
			http.Error(w, "invalid JSON body", http.StatusBadRequest)
		}
		return
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		http.Error(w, "body must contain one JSON object", http.StatusBadRequest)
		return
	}

	address, err := mail.ParseAddress(in.Email)
	if err != nil || address.Address != in.Email {
		http.Error(w, "a plain email address is required", http.StatusBadRequest)
		return
	}
	eventID, err := uuid.Parse(in.EventID)
	if err != nil {
		http.Error(w, "event_id must be a UUID", http.StatusBadRequest)
		return
	}
	name := strings.TrimSpace(in.Name)
	if len(name) > 128 || strings.ContainsAny(name, "\r\n") {
		http.Error(w, "name must be a single line of at most 128 bytes", http.StatusBadRequest)
		return
	}

	message := requests.CreateMessageRequest{
		From:        common.SenderAddress{Email: "hello@yourdomain.com", Name: ahasend.String("Your App")},
		Recipients:  []common.Recipient{{Email: in.Email, Name: ahasend.String(name)}},
		Subject:     "Welcome to Your App!",
		HtmlContent: ahasend.String("<h1>Welcome aboard 🎉</h1><p>We're glad you're here.</p>"),
		TextContent: ahasend.String("Welcome aboard! We're glad you're here."),
		Sandbox:     ahasend.Bool(sandboxMode),
	}

	// The delivery mode is part of the request body, and a key replayed with a
	// different body is rejected, so keep sandbox and live keys apart.
	mode := "live"
	if sandboxMode {
		mode = "sandbox"
	}

	response, _, err := ahasendClient.MessagesAPI.CreateMessage(
		r.Context(),
		accountID,
		message,
		api.WithIdempotencyKey("welcome-"+mode+"-"+eventID.String()),
	)
	if err != nil {
		var apiErr *api.APIError
		if errors.As(err, &apiErr) {
			log.Printf("AhaSend request failed: status=%d code=%s request_id=%s", apiErr.StatusCode, apiErr.Code, apiErr.RequestID)
			switch {
			case apiErr.IsRetryable():
				http.Error(w, "failed to send email", http.StatusBadGateway)
			case apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden:
				// The API key, not the caller's payload.
				http.Error(w, "email service misconfigured", http.StatusInternalServerError)
			default:
				http.Error(w, "email was rejected", http.StatusUnprocessableEntity)
			}
			return
		}
		http.Error(w, "unexpected error", http.StatusInternalServerError)
		return
	}

	queued, rejected := 0, 0
	for _, result := range response.Data {
		switch result.Status {
		case "queued", "scheduled":
			queued++
		default:
			rejected++
		}
	}
	if rejected > 0 || queued == 0 {
		log.Printf("AhaSend rejected recipients: count=%d", rejected)
		http.Error(w, "email was not accepted", http.StatusUnprocessableEntity)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusAccepted)
	_ = json.NewEncoder(w).Encode(map[string]int{"queued": queued})
}
```

A `2xx` from the API is not a per-recipient guarantee: every entry in `response.Data` carries its own `queued`, `scheduled`, or `error` status, so inspect them all before reporting success. On the error path, `apiErr.IsRetryable()` separates the transient failures — `429`, `5xx`, network, and the `409` returned while an earlier request with the same key is still in flight — from terminal ones such as a validation error, a missing scope, or a key reused with a different body, which is why only the former is reported to the caller as a gateway error.

This example uses a dedicated bearer token for a server-to-server endpoint. For a user-facing route, use your application's session authentication and authorization and load the recipient from server-authoritative storage instead of accepting an email address from the browser. Never expose either bearer token to client-side code or logs. Serve the route only over HTTPS, and apply per-caller request-rate and concurrency limits at your reverse proxy: an endpoint that sends to a caller-supplied address is a mail relay for anyone holding the token.

The explicit idempotency key protects a retry of the same business event outside the SDK's internal retry loop. Reuse it only for the exact same payload and never log it — the API matches a key against the request body, so the same key with a changed body is rejected with `422` rather than replayed, which is why the key carries the delivery mode. Stored outcomes replay for 24 hours, but a `5xx` is not stored and the key is released, so a retry after a server error can still send twice; reconcile an uncertain result before issuing another send when duplicates are unacceptable.

## Handle Webhooks

gorilla/mux hands your handler the `*http.Request`, so the SDK's `ParseRequest` can verify the HMAC signature over the exact raw body and return a typed event. Timestamp validation rejects stale signatures but does not deduplicate a valid delivery replayed inside the tolerance window.

> [!WARNING]
>
> `ParseRequest` consumes the request body and must see it untouched. Don't attach body-reading middleware (request dumpers, body-buffering loggers) to the router ahead of the webhook route.

```go webhook.go theme={null}
package main

import (
	"context"
	"errors"
	"net/http"
	"os"
	"sync"

	"github.com/AhaSend/ahasend-go/webhooks"
	"github.com/gorilla/mux"
)

const maxWebhookBodyBytes int64 = 30_000_000

type webhookStore interface {
	// EnqueueOnce atomically commits webhookID and durable work for event.
	// It returns false if that ID was already committed.
	EnqueueOnce(ctx context.Context, webhookID string, event webhooks.WebhookEvent) (bool, error)
}

// Local demo only: data is lost on restart and is not shared across replicas.
type memoryWebhookStore struct {
	mu sync.Mutex
	events map[string]webhooks.WebhookEvent
}

func (s *memoryWebhookStore) EnqueueOnce(ctx context.Context, id string, event webhooks.WebhookEvent) (bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, exists := s.events[id]; exists { return false, nil }
	s.events[id] = event
	return true, nil
}

func newAhaSendWebhookHandler(secret string, store webhookStore) (http.Handler, error) {
	if secret == "" {
		return nil, errors.New("AHASEND_WEBHOOK_SECRET is required")
	}
	verifier, err := webhooks.NewWebhookVerifier(secret)
	if err != nil {
		return nil, errors.New("invalid AHASEND_WEBHOOK_SECRET")
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
		event, err := verifier.ParseRequest(r)
		if err != nil {
			var maxBytesErr *http.MaxBytesError
			switch {
			case errors.As(err, &maxBytesErr):
				w.WriteHeader(http.StatusRequestEntityTooLarge)
			case errors.Is(err, webhooks.ErrMissingHeaders),
				errors.Is(err, webhooks.ErrInvalidSignature),
				errors.Is(err, webhooks.ErrExpiredTimestamp):
				w.WriteHeader(http.StatusUnauthorized)
			case errors.Is(err, webhooks.ErrUnknownEventType):
				// The signature is valid; acknowledge an event this app does not use.
				w.WriteHeader(http.StatusNoContent)
			default:
				w.WriteHeader(http.StatusBadRequest)
			}
			return
		}

		webhookID := r.Header.Get(webhooks.HeaderWebhookID)
		accepted, err := store.EnqueueOnce(r.Context(), webhookID, event)
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		if !accepted {
			w.WriteHeader(http.StatusOK)
			return
		}

		w.WriteHeader(http.StatusAccepted)
	}), nil
}

func registerAhaSendWebhook(r *mux.Router, store webhookStore) error {
	handler, err := newAhaSendWebhookHandler(os.Getenv("AHASEND_WEBHOOK_SECRET"), store)
	if err != nil {
		return err
	}
	r.Handle("/webhooks/ahasend", handler).Methods(http.MethodPost)
	return nil
}
```

`main.go` registers the route before starting the server. The included memory store is only for local testing: it loses data on restart, grows without cleanup, and cannot coordinate replicas. Replace it with a durable store before adding side effects. Startup stops if registration fails.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-app.com/webhooks/ahasend`, and copy its secret into `AHASEND_WEBHOOK_SECRET` exactly as shown (including the `aha-whsec-` prefix).

`maxWebhookBodyBytes` bounds one request, not the process, and it is sized for inbound `message.routing` deliveries, which embed the received email's attachments. Lower it if this endpoint only receives message, suppression, and domain events — the body is buffered and copied again to build the signed string before the signature is checked, so the cap sets how much memory an unauthenticated caller can make each in-flight request hold.

`EnqueueOnce` must atomically store the verified `webhook-id` and durable work/outbox record, retaining the ID for at least the delivery and retry horizon. A duplicate receives `2xx` without running the work again, while a storage failure receives `5xx` so it can be retried. Process queued work idempotently outside the request; an untracked goroutine can be lost when the process exits. Apply request-rate and concurrency limits at the reverse proxy, and never log the raw body, signature, whole event/error, subjects, addresses, or message content.

## Going Further

For substitutions, attachments and scheduled sends, use the [sending guide](https://ahasend.com/docs/send-api/send-email.md). The [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md) references explain client options, per-recipient results and retries. Use [idempotency rules](https://ahasend.com/docs/api-reference/idempotency.md) when your application retries a business operation.

## Troubleshooting

> [accordion] 401 or 403 from the API
>
> A `401` means the API key is missing, malformed, or revoked; a `403` means the key is valid but does not carry the sending domain's `messages:send` scope. Both reach the same handler branch. Verify `AHASEND_API_KEY` is set in the process environment and review the [API credentials guide](https://ahasend.com/docs/send-api/credentials.md) without printing the key.

> [accordion] Webhook verification always fails
>
> Something consumed the request body before `ParseRequest` ran. Check `r.Use(...)` middleware for body readers. Also confirm the secret matches the dashboard exactly, including the `aha-whsec-` prefix.

> [accordion] 405 Method Not Allowed on my routes
>
> `.Methods(http.MethodPost)` restricts the route to POST only. A request that matches the path with any other method is answered by gorilla/mux's built-in 405 handler, which runs without your `Router.Use` middleware. Make sure your test request uses POST.

> [accordion] 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](https://ahasend.com/docs/domains.md) and [create a send-only key](https://ahasend.com/docs/send-api/credentials.md).
* Other ways to send: [REST API](https://ahasend.com/docs/send-api/send-email.md), [SMTP](https://ahasend.com/docs/smtp.md), [CLI quickstart](https://ahasend.com/docs/cli/quickstart.md), [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md).
* Request rules: [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [scopes](https://ahasend.com/docs/api-reference/scopes.md), [idempotency](https://ahasend.com/docs/api-reference/idempotency.md), [errors](https://ahasend.com/docs/api-reference/errors.md) and [rate limits](https://ahasend.com/docs/api-reference/rate-limits.md).
* Testing and events: [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md), [CLI webhook testing](https://ahasend.com/docs/cli/webhook-testing.md), [event payloads](https://ahasend.com/docs/api-reference/webhooks.md), [signature verification](https://ahasend.com/docs/api-reference/webhooks/security.md) and [delivery retries](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).
* Data and limits: [retention](https://ahasend.com/docs/retention.md), [tracking](https://ahasend.com/docs/tracking/open-tracking.md) and [plans and feature availability](https://ahasend.com/docs/facts.md#plans-and-features).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.