> ## 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 from Google Cloud Functions (Go)

> Send transactional email from Google Cloud Run functions (formerly Cloud Functions) in Go with the AhaSend Go SDK, Secret Manager, and signed webhooks.

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

[Cloud Run functions](https://cloud.google.com/functions) run Go through the [Functions Framework](https://github.com/GoogleCloudPlatform/functions-framework-go), which hands each invocation a standard `http.ResponseWriter` and `*http.Request`. The AhaSend SDK works directly with those standard HTTP types.

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
* 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
* A Google Cloud project with the `gcloud` CLI configured

## Install the Dependencies

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

## Write the Cloud Run Functions Code

Cloud Run functions in Go live in a non-`main` package. Register each HTTP function in `init()` with `functions.HTTP`; the registered name is what you point `--entry-point` at when deploying. The dependency helpers below initialize once per instance and keep each deployed function from requiring the other function's credentials.

```go function.go theme={null}
package emailfns

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"sync"

	"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/AhaSend/ahasend-go/webhooks"
	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
	"github.com/google/uuid"
)

var (
	sendOnce       sync.Once
	ahasendClient  *api.APIClient
	accountID      uuid.UUID
	sendInitErr    error
	webhookOnce    sync.Once
	verifier       *webhooks.WebhookVerifier
	webhookInitErr error
)

func init() {
	functions.HTTP("SendWelcome", sendWelcome)
	functions.HTTP("AhaSendWebhook", ahaSendWebhook)
}

func sendDependencies() (*api.APIClient, uuid.UUID, error) {
	sendOnce.Do(func() {
		apiKey := os.Getenv("AHASEND_API_KEY")
		if apiKey == "" {
			sendInitErr = errors.New("AHASEND_API_KEY is required")
			return
		}

		var err error
		accountID, err = uuid.Parse(os.Getenv("AHASEND_ACCOUNT_ID"))
		if err != nil {
			sendInitErr = fmt.Errorf("invalid AHASEND_ACCOUNT_ID: %w", err)
			return
		}
		ahasendClient = api.NewAPIClient(api.WithAPIKey(apiKey))
	})
	return ahasendClient, accountID, sendInitErr
}

func webhookVerifier() (*webhooks.WebhookVerifier, error) {
	webhookOnce.Do(func() {
		secret := os.Getenv("AHASEND_WEBHOOK_SECRET")
		if secret == "" {
			webhookInitErr = errors.New("AHASEND_WEBHOOK_SECRET is required")
			return
		}
		verifier, webhookInitErr = webhooks.NewWebhookVerifier(secret)
	})
	return verifier, webhookInitErr
}

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

func sendWelcome(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()
	var in welcomeInput
	if err := decoder.Decode(&in); err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			w.WriteHeader(http.StatusRequestEntityTooLarge)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if in.Email == "" || len(in.Email) > 320 || len(in.Name) > 100 {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	eventID, err := uuid.Parse(in.EventID)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	client, accountID, err := sendDependencies()
	if err != nil {
		log.Print("AhaSend send function configuration is invalid")
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	message := requests.CreateMessageRequest{
		Sandbox: ahasend.Bool(true),
		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."),
	}

	response, _, err := client.MessagesAPI.CreateMessage(
		r.Context(),
		accountID,
		message,
		api.WithIdempotencyKey("sandbox-welcome-"+eventID.String()),
	)
	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,
			)
			if apiErr.Type == api.ErrorTypeIdempotencyConflict {
				// An earlier send with this key is still in flight; the outcome
				// is undecided. Tell the caller to retry with the same event_id.
				w.Header().Set("Retry-After", strconv.Itoa(apiErr.RetryAfter))
				http.Error(w, "send already in progress", http.StatusConflict)
				return
			}
			http.Error(w, "failed to send email", http.StatusBadGateway)
			return
		}
		http.Error(w, "unexpected error", http.StatusInternalServerError)
		return
	}

	if len(response.Data) != 1 || response.Data[0].Status == "error" || response.Data[0].ID == nil {
		log.Printf("AhaSend recipients rejected: count=%d", len(response.Data))
		http.Error(w, "recipient was not accepted", http.StatusBadGateway)
		return
	}
	messageID := *response.Data[0].ID
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusAccepted)
	json.NewEncoder(w).Encode(map[string]string{"message_id": messageID})
}

func ahaSendWebhook(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	verifier, err := webhookVerifier()
	if err != nil {
		log.Print("AhaSend webhook function configuration is invalid")
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	r.Body = http.MaxBytesReader(w, r.Body, 30_000_000)
	event, err := verifier.ParseRequest(r)
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			w.WriteHeader(http.StatusRequestEntityTooLarge)
			return
		}
		if errors.Is(err, webhooks.ErrUnknownEventType) {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	log.Printf("AhaSend webhook accepted: type=%s", event.GetType())
	w.WriteHeader(http.StatusOK)
}
```

Run `go mod tidy` once the source is in place. `go get` records the modules you named but not the Functions Framework's own dependencies, and both `go build` and the deploy's buildpack fail on the missing `go.sum` entries until you tidy.

The SDK automatically retries transient failures with backoff and attaches an idempotency key to every send. The stable key above also covers a later invocation for the same `event_id`, but stored outcomes expire after 24 hours and a 5xx is not stored. A retry must repeat the *exact* payload: reusing `welcome-<event_id>` with a different recipient or body returns `422`, so derive `event_id` from the signup event rather than per attempt. Reconcile an uncertain result before retrying, and persist `event_id` in your application if the welcome email must never be sent twice.

> [!NOTE]
>
> Both entry points live in the same source package, but each dependency helper initializes only when its handler runs. Give the send function only the API key and account ID, and give the webhook function only the webhook secret.

## Store the API Key in Secret Manager

Environment variables set with `--set-env-vars` are visible to anyone who can describe the function, so keep the API key (and webhook secret) in Secret Manager instead. Read each value from a prompt rather than typing it into the command: a secret written literally on a `gcloud` line stays in your shell history. `read -s` does not echo it, and `printf %s` sends it without the trailing newline that would break verification:

```bash theme={null}
read -rsp 'AhaSend API key: ' AHASEND_API_KEY
printf %s "$AHASEND_API_KEY" | gcloud secrets create ahasend-api-key --data-file=-

read -rsp 'AhaSend webhook secret: ' AHASEND_WEBHOOK_SECRET
printf %s "$AHASEND_WEBHOOK_SECRET" | gcloud secrets create ahasend-webhook-secret --data-file=-
```

Create separate runtime service accounts and grant each one **Secret Manager Secret Accessor** (`roles/secretmanager.secretAccessor`) only on the secret its function needs. Replace `PROJECT_ID` below with your project ID:

```bash theme={null}
gcloud iam service-accounts create ahasend-sender
gcloud iam service-accounts create ahasend-webhook

gcloud secrets add-iam-policy-binding ahasend-api-key \
  --member=serviceAccount:ahasend-sender@PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/secretmanager.secretAccessor

gcloud secrets add-iam-policy-binding ahasend-webhook-secret \
  --member=serviceAccount:ahasend-webhook@PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/secretmanager.secretAccessor
```

Deploying a function that runs as one of these accounts also requires **Service Account User** (`roles/iam.serviceAccountUser`) on that account for whoever runs `gcloud functions deploy`. Project owners have it already; anyone else needs it granted before the deploy below will succeed.

## Deploy

Deploy each entry point as its own function from the same source directory:

```bash theme={null}
gcloud functions deploy send-welcome \
  --gen2 --runtime=go126 --region=us-central1 --trigger-http --source=. \
  --entry-point=SendWelcome \
  --no-allow-unauthenticated \
  --service-account=ahasend-sender@PROJECT_ID.iam.gserviceaccount.com \
  --set-env-vars AHASEND_ACCOUNT_ID=your-account-uuid \
  --set-secrets AHASEND_API_KEY=ahasend-api-key:latest

gcloud functions deploy ahasend-webhook \
  --gen2 --runtime=go126 --region=us-central1 --trigger-http --source=. \
  --entry-point=AhaSendWebhook --allow-unauthenticated \
  --service-account=ahasend-webhook@PROJECT_ID.iam.gserviceaccount.com \
  --set-secrets AHASEND_WEBHOOK_SECRET=ahasend-webhook-secret:latest
```

Pass `--region` on every command (substitute your own): without it, `gcloud` falls back to the `functions/region` property and prompts when that is unset.

`go126` is an example runtime ID. Choose an available runtime at least as new as the `go` directive in your `go.mod`; a newer local toolchain may write a directive the selected builder cannot compile. `gcloud functions runtimes list --region=us-central1` shows what your project can deploy today.

`--no-allow-unauthenticated` is what keeps `send-welcome` from becoming an open mail relay — it accepts any recipient address its caller supplies, so anyone who can invoke it can send mail from your domain. A `--gen2` function is backed by a Cloud Run service of the same name, so grant only its intended caller:

```bash theme={null}
gcloud run services add-iam-policy-binding send-welcome \
  --region=us-central1 \
  --member=serviceAccount:your-caller@PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/run.invoker
```

The binding alone is not enough: every caller must also present a Google-signed OIDC ID token in an `Authorization: Bearer` header, or the request is rejected with `403` before your code runs. A calling service mints that token for its own service account with the function URL as the audience. To try the endpoint by hand, substituting the URL `gcloud` printed for `send-welcome`:

```bash theme={null}
curl -X POST FUNCTION_URL \
  -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
  -H "Content-Type: application/json" \
  -d '{"email":"new-user@example.com","name":"Jane","event_id":"3f1c0b6e-8f0a-4a2e-9f7d-2b6c1f4a9e01"}'
```

Use that token for manual testing only: a `gcloud`-printed identity token carries no audience claim, so it is replayable against any service that accepts it.

The webhook function must be `--allow-unauthenticated` because AhaSend can't attach Google IAM credentials; authentication happens via the HMAC signature the verifier checks. Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at the URL `gcloud` prints for `ahasend-webhook`, and copy its secret into the `ahasend-webhook-secret` secret exactly as shown (including the `aha-whsec-` prefix).

Timestamp validation is not replay deduplication. Before adding side effects, atomically store the `webhook-id` header together with durable work; acknowledge a duplicate with `200` without enqueueing it again. Publish to Cloud Tasks or Pub/Sub before returning success, then perform slow work in the consumer. Cloud Run functions does not guarantee work started after the HTTP response will continue.

## Test Locally

The Functions Framework can serve your function as a local HTTP server. Add a small `main` package:

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

import (
	"log"
	"os"

	"github.com/GoogleCloudPlatform/functions-framework-go/funcframework"

	_ "example.com/emailfns" // triggers init() and registers the functions
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	// Loopback only. SendWelcome carries no authentication of its own — in
	// production Google IAM is the gate — so binding 0.0.0.0 here would hand
	// everyone on your network a mail relay for your domain.
	if err := funcframework.StartHostPort("127.0.0.1", port); err != nil {
		log.Fatalf("funcframework.StartHostPort: %v", err)
	}
}
```

Then pick which registered function to serve with `FUNCTION_TARGET`:

```bash theme={null}
read -rsp 'AhaSend API key: ' AHASEND_API_KEY && export AHASEND_API_KEY
export AHASEND_ACCOUNT_ID=your-account-uuid
FUNCTION_TARGET=SendWelcome go run ./cmd

read -rsp 'AhaSend webhook secret: ' AHASEND_WEBHOOK_SECRET && export AHASEND_WEBHOOK_SECRET
FUNCTION_TARGET=AhaSendWebhook go run ./cmd
```

Prompt for the credentials rather than writing them inline here too: a key on the command line lands in your shell history and is readable from `ps` while the server runs.

## 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] Deploy fails with an entry point error
>
> The `--entry-point` value must exactly match the name registered with `functions.HTTP` (`SendWelcome`, `AhaSendWebhook`): it is the registered name, not the Go function name, and it's case-sensitive. Also confirm the source package isn't `package main`.

> [accordion] Function returns 500 after deploy
>
> Check the Cloud Logging output. Confirm the function has its required environment variables and that its runtime service account has `roles/secretmanager.secretAccessor` on the secret referenced by `--set-secrets`.

> [accordion] Webhook verification always fails
>
> Confirm the secret in Secret Manager matches the dashboard exactly, including the `aha-whsec-` prefix (watch for a trailing newline: use `printf`, not `echo`, when creating the secret). `ParseRequest` must be the first thing to read the request body on that route.

> [accordion] 401 from the AhaSend API
>
> The API key is missing, malformed, or revoked. Verify the secret binding (`--set-secrets AHASEND_API_KEY=...`) is present on the function and the key exists in your [dashboard](https://dash.ahasend.com/account/-/transactional/credentials).

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

Other language guides: [Cloud Run functions with Node.js](https://ahasend.com/docs/guides/google-cloud-functions-nodejs.md) and [Cloud Run functions with Python](https://ahasend.com/docs/guides/google-cloud-functions-python.md).

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