Skip to main content
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 run Go through the Functions Framework, 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. See send-only credentials and authentication. Keep credentials on the server. Use sandbox mode 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 and retry policy.

Prerequisites

  • Go
  • An AhaSend account with a verified sending domain
  • An API key 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

Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a verified sender, a send-only API key, 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:
quick-send.go
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.
function.go
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.
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:
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:
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:
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:
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:
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 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:
cmd/main.go
Then pick which registered function to serve with FUNCTION_TARGET:
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. The Node.js SDK and Go SDK references explain client options, per-recipient results and retries. Use idempotency rules when your application retries a business operation.

Troubleshooting

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.
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.
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.
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.
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 and Cloud Run functions with Python.