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

> Send transactional email from an Echo v5 (Go) app with the AhaSend Go SDK: KeyAuth and rate-limit middleware, sandbox sends, and verified webhooks.

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

[Echo](https://echo.labstack.com) exposes the underlying request through `c.Request()`, which can be passed directly to `verifier.ParseRequest`.

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 1.25 or newer, which Echo v5 requires
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) scoped to `messages:send:{your-domain}`, matching the domain in `From.Email`, and your account ID

## Install the SDK

```bash theme={null}
go mod init example.com/ahasend-echo
go get github.com/AhaSend/ahasend-go
go get github.com/google/uuid
go get github.com/labstack/echo/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 AHASEND_SEND_TOKEN=a-high-entropy-server-to-server-token
```

## Create the Client

Create the client and verifier once at startup and share them across handlers. Fail startup if any required credential is missing:

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

import (
	"crypto/subtle"
	"log"
	"net/http"
	"os"

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

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

// sendRateLimiter caps how fast this process can turn requests into email. The
// identifier is constant so the cap is process-wide rather than per client IP:
// the AhaSend rate limit is per account, and these callers may all arrive
// through one proxy address. Keep the sum across processes below your account
// limit.
func sendRateLimiter() echo.MiddlewareFunc {
	return middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
		Store: middleware.NewRateLimiterMemoryStoreWithConfig(
			middleware.RateLimiterMemoryStoreConfig{Rate: 20, Burst: 40},
		),
		IdentifierExtractor: func(c *echo.Context) (string, error) { return "send", nil },
	})
}

// requireSendToken rejects unauthenticated callers before they reach the rate
// limiter, so a flood of anonymous requests cannot exhaust the send budget and
// 429 legitimate traffic. KeyAuth's default lookup reads the Authorization
// header and trims the "Bearer " prefix.
func requireSendToken() echo.MiddlewareFunc {
	return middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
		Validator: func(c *echo.Context, key string, _ middleware.ExtractorSource) (bool, error) {
			return sendToken != "" && subtle.ConstantTimeCompare([]byte(key), []byte(sendToken)) == 1, nil
		},
		// Both failures already answer 401, but with different bodies: a
		// missing token reports "missing key" and a wrong one "Unauthorized".
		// Collapse them so a caller cannot tell missing from wrong.
		ErrorHandler: func(c *echo.Context, err error) error {
			return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
		},
	})
}

func main() {
	apiKey := os.Getenv("AHASEND_API_KEY")
	if apiKey == "" {
		log.Fatal("AHASEND_API_KEY is required")
	}
	sendToken = os.Getenv("AHASEND_SEND_TOKEN")
	if sendToken == "" {
		log.Fatal("AHASEND_SEND_TOKEN is required")
	}
	webhookSecret := os.Getenv("AHASEND_WEBHOOK_SECRET")
	if webhookSecret == "" {
		log.Fatal("AHASEND_WEBHOOK_SECRET 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))
	verifier, err = webhooks.NewWebhookVerifier(webhookSecret)
	if err != nil {
		log.Fatalf("failed to create webhook verifier: %v", err)
	}

	e := echo.New()
	e.Use(middleware.RequestLogger())
	e.Use(middleware.Recover())
	e.POST("/api/welcome", sendWelcome,
		requireSendToken(),
		middleware.BodyLimit(16*1024),
		sendRateLimiter(),
	)
	e.POST("/webhooks/ahasend", handleAhaSendWebhook, middleware.BodyLimit(30_000_000))
	if err := e.Start(":8080"); err != nil {
		log.Fatalf("Echo stopped: %v", err)
	}
}
```

`RequestLogger` does not capture request bodies. Do not add `BodyDump` to the webhook route: it captures the complete signed event for its callback, which can expose recipient or message data in diagnostics.

Both routes carry a `BodyLimit` so no handler reads an unbounded body. On the send route the middleware order matters: authentication runs first, so unauthenticated traffic is rejected before it can consume the rate limiter's budget and 429 legitimate callers.

## Send an Email from an Echo Handler

Echo handlers take a `*echo.Context` and return an `error`. Bind the JSON body into a struct with `c.Bind`:

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

import (
	"errors"
	"log"
	"net/http"
	"net/mail"
	"regexp"
	"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/labstack/echo/v5"
)

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}$`)

// sendWelcome runs only for callers that cleared requireSendToken.
func sendWelcome(c *echo.Context) error {
	var in welcomeInput
	if err := c.Bind(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid JSON body")
	}
	if !eventIDPattern.MatchString(in.EventID) {
		return echo.NewHTTPError(http.StatusBadRequest, "a valid event_id is required")
	}
	// Require a bare address: ParseAddress also accepts `Name <a@b.com>`, so
	// compare against the parsed address to reject anything that smuggles extra
	// addressing into this field.
	addr, err := mail.ParseAddress(in.Email)
	if err != nil || addr.Address != in.Email || len(in.Email) > 254 {
		return echo.NewHTTPError(http.StatusBadRequest, "a valid email address is required")
	}
	if len(in.Name) > 100 || strings.ContainsAny(in.Name, "\r\n") {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid name")
	}

	sandbox := true
	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 := "sandbox-welcome-" + in.EventID
	if !sandbox {
		idempotencyKey = "live-welcome-" + in.EventID
	}
	response, _, err := ahasendClient.MessagesAPI.CreateMessage(
		c.Request().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 code=%s request_id=%s", apiErr.StatusCode, apiErr.Code, apiErr.RequestID)
			switch apiErr.Type {
			case api.ErrorTypeValidation, api.ErrorTypeIdempotency:
				// The caller's payload is the problem; retrying it unchanged
				// will fail the same way.
				return echo.NewHTTPError(http.StatusBadRequest, "email request rejected")
			default:
				return echo.NewHTTPError(http.StatusBadGateway, "failed to send email")
			}
		}
		return echo.NewHTTPError(http.StatusInternalServerError, "unexpected error")
	}

	// A 2xx only means the request was accepted: each recipient carries its own
	// status, so check it rather than trusting the HTTP code.
	if len(response.Data) != 1 {
		log.Printf("AhaSend returned an unexpected result count=%d", len(response.Data))
		return echo.NewHTTPError(http.StatusBadGateway, "unexpected send result")
	}
	if response.Data[0].Status == "error" {
		reason := ""
		if response.Data[0].Error != nil {
			reason = *response.Data[0].Error
		}
		// Suppressed or undeliverable address: permanent, so do not invite a retry.
		log.Printf("AhaSend rejected the recipient reason=%q", reason)
		return echo.NewHTTPError(http.StatusBadRequest, "recipient rejected")
	}

	messageID := ""
	if response.Data[0].ID != nil {
		messageID = *response.Data[0].ID
	}
	return c.JSON(http.StatusAccepted, map[string]interface{}{
		"accepted":   true,
		"message_id": messageID,
	})
}
```

This is a server-to-server route. Never expose `AHASEND_SEND_TOKEN` to a browser; for browser-facing flows, use your application's authentication and load the recipient from its trusted user record instead of accepting an arbitrary address.

The example defaults to sandbox mode, which validates without delivery. Change `sandbox` to `false` only when you intend to send real mail. The stable business-event key protects later calls for the same logical request, so keep an `event_id` bound to the same request data: AhaSend hashes the method, path, and body behind the key, so reusing one key for a changed payload returns `422 Unprocessable Entity` rather than sending. The mode prefix keeps the sandbox and live attempts on separate keys, so flipping `sandbox` for the same `event_id` sends instead of hitting that `422`.

The SDK also generates a key when none is supplied and reuses a request's key across its automatic exponential-backoff retries. Stored outcomes replay for 24 hours, but a 5xx is not stored, so even a same-key retry after a server error can still send twice.

## Handle Webhooks

Echo exposes the underlying `*http.Request` via `c.Request()`, so the SDK's `ParseRequest` works directly: it verifies the HMAC signature and timestamp, then returns a typed event.

> [!WARNING]
>
> `ParseRequest` consumes the request body and must see its original bytes. Don't call `c.Bind` first. Keep the route-specific `BodyLimit`: it wraps rather than pre-consumes the stream and prevents the Go SDK's unbounded `io.ReadAll` from exhausting memory. Skip `BodyDump` because it captures the sensitive event body, even though Echo restores the stream afterward.

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

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

	"github.com/AhaSend/ahasend-go/webhooks"
	"github.com/labstack/echo/v5"
)

func handleAhaSendWebhook(c *echo.Context) error {
	event, err := verifier.ParseRequest(c.Request())
	if err != nil {
		if errors.Is(err, echo.ErrStatusRequestEntityTooLarge) {
			return c.NoContent(http.StatusRequestEntityTooLarge)
		}
		switch {
		case errors.Is(err, webhooks.ErrUnknownEventType):
			return c.NoContent(http.StatusNoContent)
		case errors.Is(err, webhooks.ErrMissingHeaders),
			errors.Is(err, webhooks.ErrInvalidSignature),
			errors.Is(err, webhooks.ErrExpiredTimestamp),
			errors.Is(err, webhooks.ErrInvalidPayload):
			return c.NoContent(http.StatusBadRequest)
		default:
			return c.NoContent(http.StatusInternalServerError)
		}
	}

	webhookID := c.Request().Header.Get(webhooks.HeaderWebhookID)
	log.Printf("verified AhaSend webhook type=%s webhook_id=%s", event.GetType(), webhookID)

	return c.NoContent(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).

Timestamp tolerance is not replay deduplication: the same valid delivery can be replayed within the window. This example intentionally performs no business side effects. Before adding any, atomically commit the verified `webhook-id` together with durable queue/outbox work, acknowledge duplicates with 2xx, and process the durable work idempotently. Do not acknowledge and then start an in-process goroutine; a crash can lose the event permanently.

## 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
>
> Ensure nothing called `c.Bind` or otherwise consumed the body before `ParseRequest`. `BodyLimit` is compatible and should stay on the route; `BodyDump` restores the body but should be skipped because its callback receives the sensitive event payload. Also confirm the secret matches the dashboard exactly, including the `aha-whsec-` prefix.

> [accordion] c.Bind rejects my request or leaves my struct empty
>
> Echo binds by `Content-Type`. With a body but no recognized `Content-Type`, `c.Bind` returns a `415 Unsupported Media Type` error, which the handler above reports as a 400 — send `Content-Type: application/json`. A request with no body at all binds successfully and leaves every field zero-valued, which is why `event_id` and `email` are validated explicitly.

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