http.ResponseWriter and *http.Request. The AhaSend SDK works directly with those standard HTTP types.
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 inFrom.Email(usemessages:send:allonly if the key must cover several domains), and your account ID - A Google Cloud project with the
gcloudCLI configured
Install the Dependencies
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
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:
roles/secretmanager.secretAccessor) only on the secret its function needs. Replace PROJECT_ID below with your project ID:
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:--region on every command (substitute your own): without it, gcloud falls back to the functions/region property and prompts when that is unset.
Pick a --runtime at least as new as the go directive go mod init wrote into go.mod — go126 builds a module declaring go 1.26 or older, and a newer local toolchain will write a directive the runtime’s builder rejects. 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:
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:
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 smallmain package:
cmd/main.go
FUNCTION_TARGET:
ps while the server runs.
Going Further
- Templating: set
Substitutions: map[string]interface{}{"first_name": "Jane"}and use{{ first_name }}in the subject or body. - Batch sends:
Recipientsaccepts up to 100 entries; each gets a separate message and its own entry inresponse.Data. The call can return2xxwhile individual entries carryStatus: "error"and a nilID, so check every entry rather than only the HTTP status. - 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. - Cold starts: the client and verifier are cached per instance and reused across warm invocations. Setting
--min-instances=1can reduce cold starts for latency-sensitive sends, but scaling and instance replacement can still start new instances. - Other platforms: deploying on Azure instead? See the Azure Functions guide; for long-running servers, start with Gin or chi.
- Attachments: add
AttachmentswithData,ContentType, andFileName. SetBase64: truefor binary files such as PDFs.
DomainsAPI, SuppressionsAPI, StatisticsAPI, and more).
Troubleshooting
Deploy fails with an entry point error
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.Function returns 500 after deploy
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.Webhook verification always fails
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.401 from the AhaSend API
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.400 error mentioning the from address
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.
