> ## 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 chi (Go)

> Send transactional email from a Go chi router with the AhaSend Go SDK: a token-protected send handler, idempotent retries, and HMAC-verified webhooks.

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

[chi](https://github.com/go-chi/chi) handlers are plain `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](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

* Go installed
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) 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

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

## 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.

## 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 WELCOME_ENDPOINT_TOKEN=replace-with-a-long-random-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:

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

import (
	"log"
	"net/http"
	"os"
	"time"

	"github.com/AhaSend/ahasend-go/api"
	"github.com/AhaSend/ahasend-go/webhooks"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/google/uuid"
)

var (
	ahasendClient *api.APIClient
	accountID     uuid.UUID
	verifier      *webhooks.WebhookVerifier
	welcomeToken  string
)

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

	var err error
	accountID, err = uuid.Parse(os.Getenv("AHASEND_ACCOUNT_ID"))
	if err != nil {
		log.Fatalf("invalid AHASEND_ACCOUNT_ID: %v", err)
	}

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

	webhookSecret := os.Getenv("AHASEND_WEBHOOK_SECRET")
	if webhookSecret == "" {
		log.Fatal("AHASEND_WEBHOOK_SECRET is required")
	}
	verifier, err = webhooks.NewWebhookVerifier(webhookSecret)
	if err != nil {
		log.Fatalf("failed to create webhook verifier: %v", err)
	}

	welcomeToken = os.Getenv("WELCOME_ENDPOINT_TOKEN")
	if welcomeToken == "" {
		log.Fatal("WELCOME_ENDPOINT_TOKEN is required")
	}

	r := chi.NewRouter()
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)
	r.Post("/api/welcome", sendWelcome)
	r.Post("/webhooks/ahasend", handleAhaSendWebhook)
	server := &http.Server{
		Addr:              ":8080",
		Handler:           r,
		ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
	log.Fatal(server.ListenAndServe())
}
```

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`:

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

import (
	"crypto/subtle"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"regexp"

	"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"
)

// sandbox runs the send through validation without delivering anything.
// Set it to false only when you intend to send real mail.
const sandbox = true

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

var eventIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,200}$`)

func sendWelcome(w http.ResponseWriter, r *http.Request) {
	provided := []byte(r.Header.Get("Authorization"))
	expected := []byte("Bearer " + welcomeToken)
	if subtle.ConstantTimeCompare(provided, expected) != 1 {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
	var in welcomeInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "a JSON body is required", http.StatusBadRequest)
		return
	}
	if in.Email == "" || !eventIDPattern.MatchString(in.EventID) {
		http.Error(w, "email and a valid event_id are required", 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(in.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(sandbox),
	}

	idempotencyKey := "live-welcome-" + in.EventID
	if sandbox {
		idempotencyKey = "sandbox-welcome-" + in.EventID
	}

	response, _, err := ahasendClient.MessagesAPI.CreateMessage(
		r.Context(),
		accountID,
		message,
		api.WithIdempotencyKey(idempotencyKey),
	)
	if err != nil {
		var apiErr *api.APIError
		if errors.As(err, &apiErr) {
			log.Printf("AhaSend API error status=%d request_id=%s", apiErr.StatusCode, apiErr.RequestID)
			http.Error(w, "failed to send email", http.StatusBadGateway)
			return
		}
		log.Print("AhaSend send failed before a response was received")
		http.Error(w, "unexpected error", http.StatusInternalServerError)
		return
	}

	if len(response.Data) != 1 || response.Data[0].Status == "error" {
		log.Printf("AhaSend rejected the recipient results=%d", len(response.Data))
		http.Error(w, "recipient rejected", http.StatusBadGateway)
		return
	}

	messageID := ""
	if response.Data[0].ID != nil {
		messageID = *response.Data[0].ID
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusAccepted)
	json.NewEncoder(w).Encode(map[string]interface{}{"accepted": true, "message_id": messageID})
}
```

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`](https://github.com/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](https://ahasend.com/docs/send-api/sandbox.md).

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.

> [!WARNING]
>
> `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.

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

import (
	"errors"
	"log"
	"net/http"

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

const maxWebhookBodyBytes int64 = 30_000_000

func handleAhaSendWebhook(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
	event, err := verifier.ParseRequest(r)
	if err != nil {
		var maxErr *http.MaxBytesError
		switch {
		case errors.Is(err, webhooks.ErrUnknownEventType):
			// The signature was already verified. Acknowledge future event types.
			w.WriteHeader(http.StatusOK)
		case errors.As(err, &maxErr):
			w.WriteHeader(http.StatusRequestEntityTooLarge)
		case errors.Is(err, webhooks.ErrMissingHeaders),
			errors.Is(err, webhooks.ErrInvalidSignature),
			errors.Is(err, webhooks.ErrExpiredTimestamp):
			w.WriteHeader(http.StatusUnauthorized)
		default:
			w.WriteHeader(http.StatusBadRequest)
		}
		return
	}

	switch event.(type) {
	case *webhooks.MessageDeliveredEvent:
		log.Print("message delivered")
	case *webhooks.MessageBouncedEvent:
		log.Print("message bounced")
	case *webhooks.MessageOpenedEvent:
		log.Print("message opened")
	default:
		log.Printf("unhandled event type: %s", event.GetType())
	}

	w.WriteHeader(http.StatusOK)
}
```

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).

> [!WARNING]
>
> 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

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 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](https://dash.ahasend.com/account/-/transactional/credentials).

> [accordion] 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.

> [accordion] 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`.

> [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.