nodejs_compat flag, so a Worker gets the same typed client, retries and webhook verifier as a Node server.
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 infrom.email, and your account ID
Install the SDK
Configure Secrets
Store credentials as Worker secrets, never in thevars 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
src/index.ts
Send an Email from a Worker
Secrets arrive on theenv 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
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.
Handle Webhooks
The webhooks subpath verifies signatures with Web Crypto, which workerd provides natively. The SDK’snextRouteHandler 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
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
- Templating: pass
substitutionsper recipient and use{{ variable }}in the subject or body. - Batch sends:
recipientsaccepts up to 100 entries; each one gets a separate, individually-substituted message. - Your own idempotency keys: pass
{ idempotencyKey: "order-123" }as the second argument tosend()to dedupe retries of the same business operation. Reusing a key after the server’s 24-hour retention window does not prevent a new send. - Attachments: pass
attachments: [{ data, content_type, file_name, base64: true }]. Setbase64: truefor binary files such as PDFs. - Queues: for bursts, push the send onto a Cloudflare Queue and send from the consumer. Derive a stable AhaSend idempotency key from the queue message or business ID, acknowledge each message only after an accepted send, and configure a dead-letter queue; Cloudflare retries failed batches and otherwise discards messages after their retry limit.
Troubleshooting
AhaSendConfigurationError refusing to construct in a browser-like environment
AhaSendConfigurationError refusing to construct in a browser-like environment
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.Webhook verification always returns 400
Webhook verification always returns 400
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.401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
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.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.
