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

# Go Email SDK (ahasend-go)

> Official AhaSend Go SDK (ahasend-go): install, send transactional email, configure retries and rate limits, handle errors, and verify webhooks.

The AhaSend Go SDK lets you send email, handle recipient results and verify webhooks; this guide covers installation and the client options.

This page is the SDK reference. To wire it into a specific framework, pick a guide from the sidebar.

Before sending, [verify a sending domain](https://ahasend.com/docs/domains.md) and create an [API key](https://ahasend.com/docs/send-api/credentials.md) plus an account ID. Give the key the domain-specific `messages:send:{your-domain}` scope when it only sends from one domain, and keep it in a server-side secret store.

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

## Install

| Fact | Value |
| - | - |
| Module | `github.com/AhaSend/ahasend-go` |
| Minimum Go version | 1.18 (frameworks may require newer) |
| License | MIT |
| Source | [ahasend-go](https://github.com/AhaSend/ahasend-go) |

```bash theme={null}
go mod init example.com/ahasend-go-sdk
go get github.com/AhaSend/ahasend-go
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.

## Send Your First Email

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

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"

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

func main() {
	apiKey := os.Getenv("AHASEND_API_KEY")
	if apiKey == "" {
		log.Fatal("AHASEND_API_KEY is required")
	}
	welcomeEventID := os.Getenv("WELCOME_EVENT_ID")
	if welcomeEventID == "" {
		welcomeEventID = uuid.NewString() // Use a stable business-event ID in production.
	}

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

	client := api.NewAPIClient(api.WithAPIKey(apiKey))

	response, _, err := client.MessagesAPI.CreateMessage(
		context.Background(),
		accountID,
		requests.CreateMessageRequest{
			From:        common.SenderAddress{Email: "hello@yourdomain.com", Name: ahasend.String("Your App")},
			Recipients:  []common.Recipient{{Email: "user@example.com", Name: ahasend.String("Jane")}},
			Subject:     "Welcome to Your App",
			HtmlContent: ahasend.String("<h1>Welcome aboard</h1>"),
			TextContent: ahasend.String("Welcome aboard"),
			Sandbox:     ahasend.Bool(true),
		},
		api.WithIdempotencyKey(fmt.Sprintf("welcome-%s", welcomeEventID)),
	)
	if err != nil {
		var apiErr *api.APIError
		if errors.As(err, &apiErr) {
			log.Printf("AhaSend request failed: status=%d type=%s request_id=%s", apiErr.StatusCode, apiErr.Type, apiErr.RequestID)
		} else {
			log.Print("AhaSend request failed")
		}
		os.Exit(1)
	}

	queued, rejected := 0, 0
	for _, r := range response.Data {
		switch r.Status {
		case "queued", "scheduled":
			queued++
		default:
			rejected++
		}
	}
	log.Printf("AhaSend result: queued=%d rejected=%d", queued, rejected)
	if rejected > 0 {
		os.Exit(1)
	}
}
```

Run it with `go run main.go`. The example sets `Sandbox` to true, so nothing is delivered even though the message still runs through validation and fires the corresponding webhooks. Replace the example's idempotency key with a stable, server-derived identifier for the actual business operation, and do not carry a key from a sandbox send over to the live one: flipping `Sandbox` changes the request body, and the API rejects a reused key whose payload differs with `422` instead of sending. See [Sandbox Mode](https://ahasend.com/docs/send-api/sandbox.md).

Note the loop. `Recipients` takes up to 100 addresses and each one becomes its own message, so `response.Data` holds one entry per recipient. Reading `Data[0]` and calling it done would hide the rest.

Optional fields are pointers, so the SDK exports helpers such as `ahasend.String()` to build them inline. Beyond the fields above: `Tags []string`, `Substitutions map[string]interface{}` for `{{ variable }}` templating, plus attachments, scheduling, tracking, and retention.

## Configure the Client

The following configuration fragment belongs inside your startup function and requires `import "time"`. Build the client once at startup and share it. It holds rate-limit, retry, and idempotency state.

`api.NewAPIClientFromEnv()` builds the same client from the `AHASEND_*` environment variables. It does not fail when `AHASEND_API_KEY` is unset — construction succeeds and every request fails instead, so check the variable yourself at startup. To override the key for one call, pass `api.WithRequestAPIKey(key)` as that method's final request option. Keep `context.Context` for cancellation, deadlines, and request-scoped values rather than using it for optional parameters.

| Option | Purpose |
| - | - |
| `api.WithAPIKey(key)` | Your API key, required |
| `api.WithRetryConfig(cfg)` | Retry policy: attempts, backoff strategy, delays |
| `client.SetSendMessageRateLimit(rps, burst)` | Pace outbound sends |
| `client.SetStatisticsRateLimit(rps, burst)` | Pace statistics calls, which are limited harder |
| `api.WithRequestAPIKey(key)` | Override the key for one request |

```go theme={null}
retryConfig := api.RetryConfig{
	Enabled:         true,
	MaxRetries:      3,
	BackoffStrategy: api.BackoffExponential,
	BaseDelay:       time.Second,
	MaxDelay:        30 * time.Second,
}

client := api.NewAPIClient(
	api.WithAPIKey(apiKey),
	api.WithRetryConfig(retryConfig),
)

// Keep aggregate requests across all client instances within your account limit.
client.SetSendMessageRateLimit(100, 200) // requests per second, burst
```

See [API rate limits](https://ahasend.com/docs/api-reference/rate-limits.md). Pacing is already on when you build the client, preset to the standard account limits: 100 requests per second with a 200 burst for sends and other general endpoints, 1 per second with no burst for statistics. These setters move a bucket to whatever limit your account actually has; they do not switch pacing on. That matters in both directions — an account provisioned above 100 sends per second stays capped at the default until you raise the bucket. The token buckets are local to one client instance, so they do not coordinate across application replicas and do not eliminate API `429` responses.

## Errors

```go theme={null}
import (
	"errors"
	"net/http"

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

var apiErr *api.APIError
if errors.As(err, &apiErr) {
	switch apiErr.StatusCode {
	case http.StatusBadRequest:
		// validation problem with the request
	case http.StatusUnauthorized:
		// key malformed or revoked
	case http.StatusForbidden:
		// key lacks the required scope
	case http.StatusUnprocessableEntity:
		// idempotency key reused with a different request payload
	case http.StatusTooManyRequests:
		// configured retries are exhausted, or retries are disabled
	}
}
```

`apiErr` also carries `Type`, `RequestID`, `Message`, `RetryAfter` on `429` and idempotency conflicts, and the raw response body in `Raw`. `Type` is the field to branch and log on; the struct has a `Code` field, but the SDK never fills it.

Do not rely on `StatusCode` alone. Failures raised before the request leaves the process — no API key configured, or a request body that fails client-side validation — are also `*api.APIError`, but they carry `StatusCode` 0 and only `Type` (`api.ErrorTypeAuthentication`, `api.ErrorTypeValidation`) identifies them. A switch on status code alone drops them silently, so branch on `Type` and keep a default arm.

Treat `Message`, `Raw`, and `err.Error()` as sensitive: an API response can repeat addresses, content, headers, or other request data. Log only allowlisted fields such as status, error type, request ID, and aggregate counts.

When retries are enabled, a surfaced `429` or server error has exhausted the configured retry attempts. Disabling retries or setting the retry count to zero makes the first response surface immediately.

## Retries and Idempotency

Retries are on by default: the SDK retries network failures, `429`, and `5xx` responses with the configured backoff, and leaves every other `4xx` alone unless you opt in with `RetryConfig.RetryClientErrors`. It attaches an idempotency key to POST operations and reuses that key across its own retry attempts.

> [!NOTE]
>
> Stored outcomes replay for 24 hours, covering 2xx and deterministic 4xx responses. Server errors are not stored, so a retry after a 5xx can still result in a second send. Pass your own key derived from a stable business identifier when a duplicate would be expensive. API key creation is the exception: because its response carries a one-time secret, that outcome replays for only 5 minutes, after which the same key creates a second credential.

```go theme={null}
response, _, err := client.MessagesAPI.CreateMessage(
	ctx, accountID, message,
	api.WithIdempotencyKey(fmt.Sprintf("order-confirmation-%s", orderID)),
)
```

Reuse a caller-provided key only for the same exact request payload, and never log it. If the original keyed request is still running, the API can return `409`; the SDK surfaces this as `api.ErrorTypeIdempotencyConflict` with `RetryAfter` and does not retry it automatically. For email sends, reconcile the business operation or explicitly decide whether a possible duplicate is preferable to a possible missed send before retrying with the same key.

## Services

| Service | Description |
| - | - |
| `MessagesAPI` | Send messages, retrieve status and history, cancel |
| `DomainsAPI` | Add, verify, and configure sending domains |
| `WebhooksAPI` | Manage webhook endpoints |
| `StatisticsAPI` | Deliverability, bounce, and delivery-time reports |
| `SuppressionsAPI` | Manage suppression lists |
| `RoutesAPI` | Inbound email routing |
| `AccountsAPI` | Account and member management |
| `APIKeysAPI` | API key management |
| `SubAccountsAPI` | Sub-accounts, usage, and child API keys |
| `SMTPCredentialsAPI` | SMTP credential management |
| `UtilityAPI` | API health check |

## Webhooks

```go theme={null}
import (
	"context"
	"errors"
	"net/http"
	"os"

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

const maxWebhookBodyBytes int64 = 30_000_000

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

func newWebhookHandler(store webhookStore) http.Handler {
	secret := os.Getenv("AHASEND_WEBHOOK_SECRET")
	if secret == "" {
		panic("AHASEND_WEBHOOK_SECRET is required")
	}
	verifier, err := webhooks.NewWebhookVerifier(secret)
	if err != nil {
		panic("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):
				// Acknowledge a validly signed event this application 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)
	})
}
```

`ParseRequest` verifies an `*http.Request` directly, which covers chi, Echo, Gin, and gorilla/mux since they all wrap `net/http`. It reads the body without imposing a size limit, so apply `http.MaxBytesReader` first. For fasthttp routers such as Fiber, enforce an equivalent limit, read the exact body bytes, and call `verifier.Parse(body, headers)` with an `http.Header` carrying `webhook-id`, `webhook-timestamp`, and `webhook-signature`.

> [!WARNING]
>
> `ParseRequest` consumes the request body, so verification fails if middleware has already read it without restoring the exact bytes. Reject an empty secret before constructing the verifier, and pass the value exactly as the dashboard shows it, `aha-whsec-` prefix included.

Signature timestamp validation is not replay protection: the same signed request can be delivered again inside the tolerance window. After verification, atomically commit the `webhook-id` and a durable work/outbox record, retaining IDs for at least the webhook delivery and retry horizon. A duplicate should receive `2xx` without running the handler again; a storage failure should receive `5xx`. Process the durable work idempotently outside the request. Apply concurrency and request-rate limits at the reverse proxy so concurrent maximum-size bodies cannot exhaust the application. Never log the raw body, signature, event object, subject, recipient, or whole verification error.

Events implement `webhooks.WebhookEvent` with `GetType()` and `GetTimestamp()`. The SDK covers the message, suppression, domain, contact, and route event types. Helpers such as `webhooks.IsMessageEvent()` and `webhooks.GetMessageEventData()` handle common message events without a full type switch.

## Framework Guides

> [card] [Gin](https://ahasend.com/docs/guides/gin.md)

> [card] [Echo](https://ahasend.com/docs/guides/echo.md)

> [card] [Fiber](https://ahasend.com/docs/guides/fiber.md)

> [card] [chi](https://ahasend.com/docs/guides/chi.md)

> [card] [gorilla/mux](https://ahasend.com/docs/guides/gorilla-mux.md)

> [card] [Cloud Run Functions](https://ahasend.com/docs/guides/google-cloud-functions.md)

Azure Functions is in the sidebar too. Building in Node.js instead? See the [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md).

## Source and Support

MIT licensed, developed at [github.com/AhaSend/ahasend-go](https://github.com/AhaSend/ahasend-go), with reference docs on [pkg.go.dev](https://pkg.go.dev/github.com/AhaSend/ahasend-go). The [AhaSend CLI](https://github.com/AhaSend/ahasend-cli) is built on this SDK.

Use [Go over SMTP](https://ahasend.com/docs/smtp/golang.md) if your app already has an SMTP client.

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