Skip to main content
This guide shows how to send transactional email from Cloudflare Workers with AhaSend, keep credentials on the server, and test a sandbox send. The SDK runs on workerd natively and needs no nodejs_compat flag, so a Worker gets the same typed client, retries and webhook verifier as a Node server. 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

  • A Workers project set up with Wrangler
  • An AhaSend account with a verified sending domain
  • An API key scoped to messages:send:{your-domain}, matching the domain in from.email, and your account ID
If you’re starting from scratch:

Install the SDK

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 Workers project, add .dev.vars* to .gitignore. Create .dev.vars with AHASEND_API_KEY, AHASEND_ACCOUNT_ID, and AHASEND_FROM (an address on your verified domain). Save this as quick-worker.js:
quick-worker.js
Bind fetch to globalThis when passing it to the SDK in a Worker; this avoids an Illegal invocation error in workerd. Run npx wrangler dev quick-worker.js --local --ip 127.0.0.1 --port 8787. In a second terminal, run curl --fail-with-body -X POST http://127.0.0.1:8787. A successful response has HTTP 202 and a list of accepted recipient statuses. Stop Wrangler after the check. This fixed-recipient sandbox handler is for local use only; the longer handler below adds authentication for deployment.

Configure Secrets

Store credentials as Worker secrets, never in the vars block of your Wrangler configuration file — wrangler.jsonc in projects scaffolded today, wrangler.toml in older ones — which ends up in source control:
AHASEND_SEND_TOKEN is a high-entropy credential for the server-to-server example below. Do not expose it to browser code. For local development, put the same values in a .dev.vars file. Cloudflare’s docs tell you to ignore local secret files explicitly; add these patterns before creating the file:
.gitignore
Type the bindings in your Worker:
src/index.ts

Send an Email from a Worker

Pass fetch: fetch.bind(globalThis) when constructing the SDK client in workerd. Cloudflare requires the global binding when code stores and later calls fetch; without it, SDK 0.2.1 can fail with an Illegal invocation error. Secrets arrive on the env argument, which is how Workers deliver bindings whether or not Node compatibility is on — process.env is not a substitute, since it only carries your bindings under the nodejs_compat_populate_process_env flag. The client is built per request rather than at module scope. This example is a server-to-server endpoint protected by its own bearer token. For browser-facing flows, authenticate the user with your application and load the recipient from a trusted user record; never accept an arbitrary recipient from an unauthenticated request.
src/index.ts
A 202 is multi-status: result.data carries one entry per recipient, and an individual recipient can come back status: "error" with a null id (a suppressed address, say) while the call itself succeeds, so check every entry, not just the first. The stable eventId also makes a later retry of the same welcome operation use the same AhaSend idempotency key. The example defaults to sandbox = true, which validates without delivering. Change it to false only after you have tested the route and intend to send real mail. The mode belongs in the idempotency key because the API matches a key against the request body it was first used with: flipping sandbox under an already-used key is a different body, and the API answers 422 instead of sending. Keep each eventId bound to the same logical request data for the same reason.
Isolates are reused across requests, so never cache the client in a module-scope variable keyed to one request’s env. Constructing it inside fetch is cheap and keeps each request’s credentials scoped to that request.

Handle Webhooks

The webhooks subpath verifies signatures with Web Crypto, which workerd provides natively. The SDK’s nextRouteHandler is its web-standard Request/Response adapter despite the name, so it also works in Workers. It streams the authentic body bytes, stops above the configured limit, and returns an opaque 400 or 413 for verification failures:
src/index.ts
The adapter awaits parse() before invoking the callback. The callback therefore receives a trusted, parsed event; this verification-only example deliberately performs no business side effects and logs only the event type and the delivery ID, never the event payload or a recipient address. Keep maxBodyBytes within your isolate’s memory and concurrency budget; it may narrow, but never raise, the verifier’s fixed 30,000,000-byte ceiling. Signature timestamp checking is not replay protection. Before adding side effects, atomically commit the verified webhook-id together with durable processing work (for example, in a Durable Object or D1 transaction), acknowledge duplicates with 2xx, and process that work idempotently. Do not mark an ID handled in one operation and enqueue its work in another: a crash between them loses the event. Finish that commit before you return the response. Workers cancel async work that is neither awaited nor handed to ctx.waitUntil() once the invocation ends, so a floating promise silently drops the event. ctx is the third fetch argument, which the example above omits because it does no post-response work; waitUntil() extends the invocation for at most 30 seconds after the response, which makes it a place for logging and metrics rather than for the durability step. Create the webhook in your AhaSend dashboard pointing at https://my-worker.your-subdomain.workers.dev/webhooks/ahasend, and copy its secret into the AHASEND_WEBHOOK_SECRET secret exactly as shown, including the aha-whsec- prefix.

Deploy

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 client refuses to construct where window, document, or a service-worker scope is present without a server-runtime signal, so the bearer key can never reach a browser bundle. A Worker does not trip this. Check that the import sits in your Worker entry point rather than in front-end code that the same build pulls in, and do not silence it with dangerouslyAllowBrowser.
Two usual causes are a body parser consuming or re-serializing the body before nextRouteHandler sees it, and a secret missing its aha-whsec- prefix. Pass the original Request to the adapter without reading its body first.
In the setup shown here, read the key from the env argument, and confirm the secret is set with npx wrangler secret put for production and in .dev.vars locally.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.