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

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

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

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

  • Templating: pass substitutions per recipient and use {{ variable }} in the subject or body.
  • Batch sends: recipients accepts up to 100 entries; each one gets a separate, individually-substituted message.
  • Your own idempotency keys: pass { idempotencyKey: "order-123" } as the second argument to send() 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 }]. Set base64: true for 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.
See the Node.js SDK guide for client configuration in depth, and the API reference for every endpoint the SDK exposes.

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.