Skip to main content
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.

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

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. Pick a --runtime at least as new as the go directive go mod init wrote into go.modgo126 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:
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

  • 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 and its own entry in response.Data. The call can return 2xx while individual entries carry Status: "error" and a nil ID, 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=1 can 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 Attachments with Data, ContentType, and FileName. Set Base64: true for binary files such as PDFs.
See the API reference for every service the SDK exposes (DomainsAPI, SuppressionsAPI, StatisticsAPI, and more).

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.