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

# How to Send Email with Echo (Go)

> Send transactional email from an Echo (Go) app with the AhaSend email API: SDK setup, send handlers, and HMAC-verified delivery webhooks.

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

## 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/-/settings/api-keys) scoped to `messages:send:{your-domain}`, matching the domain in `From.Email`, and your account ID

## Install the SDK

```bash theme={null}
go get github.com/AhaSend/ahasend-go
go get github.com/labstack/echo/v5
```

## 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(1_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.
</Warning>

```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.ErrMissingHeaders),
			errors.Is(err, webhooks.ErrInvalidSignature),
			errors.Is(err, webhooks.ErrExpiredTimestamp),
			errors.Is(err, webhooks.ErrInvalidPayload),
			errors.Is(err, webhooks.ErrUnknownEventType):
			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

* **Templating**: set `Substitutions: map[string]interface{}{"first_name": "Jane"}` and use `{{ first_name }}` in the subject or body.
* **Batch sends**: `Recipients` accepts up to 100 entries; each gets a separate message.
* **Tags**: set `Tags: []string{"welcome"}` to filter messages in list and statistics queries. Webhooks are scoped by domain, not by tag, and message events do not carry the tags.
* **Rate limits**: the client already throttles sends to AhaSend's published limit of 100 requests per second with a 200-request burst, and the limit is per account rather than per key. Running several processes against one account? Use `ahasendClient.SetSendMessageRateLimit(requestsPerSecond, burst)` to give each a share that adds up to no more than the account limit.
* **Other frameworks**: the same SDK patterns work in [Gin](/docs/guides/gin), [Fiber](/docs/guides/fiber), and [chi](/docs/guides/chi).
* **Attachments**: add `Attachments` with `Data`, `ContentType`, and `FileName`. Set `Base64: true` for binary files such as PDFs.

See the [API reference](/docs/api-reference) for every service the SDK exposes (`DomainsAPI`, `SuppressionsAPI`, `StatisticsAPI`, and more).

## Troubleshooting

<AccordionGroup>
  <Accordion title="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/-/settings/api-keys).
  </Accordion>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>
