> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# How to Send Email with Cloudflare Workers

> Send transactional email from Cloudflare Workers with the AhaSend SDK, which runs on workerd natively, plus signed webhook verification.

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](https://developers.cloudflare.com/workers/wrangler/)
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/settings/api-keys) scoped to `messages:send:{your-domain}`, matching the domain in `from.email`, and your account ID

If you're starting from scratch:

<CodeGroup>
  ```bash npm theme={null}
  npm create cloudflare@latest -- my-worker
  ```

  ```bash pnpm theme={null}
  pnpm create cloudflare@latest my-worker
  ```

  ```bash yarn theme={null}
  yarn create cloudflare my-worker
  ```

  ```bash bun theme={null}
  bun create cloudflare@latest my-worker
  ```
</CodeGroup>

## Install the SDK

<CodeGroup>
  ```bash npm theme={null}
  npm install @ahasend/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk
  ```
</CodeGroup>

## 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:

```bash theme={null}
npx wrangler secret put AHASEND_API_KEY
npx wrangler secret put AHASEND_ACCOUNT_ID
npx wrangler secret put AHASEND_WEBHOOK_SECRET
npx wrangler secret put AHASEND_SEND_TOKEN
```

`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:

```text .gitignore theme={null}
.dev.vars*
.env*
```

Type the bindings in your Worker:

```ts src/index.ts theme={null}
export interface Env {
  AHASEND_API_KEY: string;
  AHASEND_ACCOUNT_ID: string;
  AHASEND_WEBHOOK_SECRET: string;
  AHASEND_SEND_TOKEN: string;
}
```

## 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.

```ts src/index.ts theme={null}
import { AhaSendClient, AhaSendAPIError } from "@ahasend/sdk";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === "POST" && url.pathname === "/api/welcome") {
      if (
        !env.AHASEND_SEND_TOKEN ||
        request.headers.get("authorization") !== `Bearer ${env.AHASEND_SEND_TOKEN}`
      ) {
        return new Response("Unauthorized", { status: 401 });
      }

      // Bound the read before it happens: Workers accept request bodies of
      // 100 MB and up, and `request.json()` buffers all of it into a 128 MB
      // isolate. A body with no declared length is refused outright.
      const declaredLength = Number(request.headers.get("content-length") ?? NaN);
      if (!Number.isFinite(declaredLength) || declaredLength > 4096) {
        return Response.json({ error: "Payload too large" }, { status: 413 });
      }

      let body: { email?: unknown; name?: unknown; eventId?: unknown };
      try {
        body = await request.json();
      } catch {
        return Response.json({ error: "Invalid JSON" }, { status: 400 });
      }

      if (
        typeof body.email !== "string" ||
        (body.name !== undefined && typeof body.name !== "string") ||
        typeof body.eventId !== "string" ||
        !/^[A-Za-z0-9._:-]{1,200}$/.test(body.eventId)
      ) {
        return Response.json({ error: "Invalid request" }, { status: 400 });
      }

      const ahasend = new AhaSendClient({
        apiKey: env.AHASEND_API_KEY,
        accountId: env.AHASEND_ACCOUNT_ID,
      });

      try {
        const sandbox = true;
        const result = await ahasend.messages.send({
          from: { email: "hello@yourdomain.com", name: "Your App" },
          recipients: [{ email: body.email, name: body.name }],
          subject: "Welcome to Your App",
          html_content: "<h1>Welcome aboard</h1><p>We're glad you're here.</p>",
          text_content: "Welcome aboard. We're glad you're here.",
          sandbox,
        }, {
          idempotencyKey: `${sandbox ? "sandbox" : "live"}-welcome-${body.eventId}`,
        });

        const rejected = result.data.filter((r) => r.status === "error");
        if (rejected.length > 0) {
          console.warn("AhaSend rejected a recipient", { failedCount: rejected.length });
          return Response.json({ error: "Recipient rejected" }, { status: 502 });
        }

        return Response.json({ accepted: true });
      } catch (err) {
        if (err instanceof AhaSendAPIError) {
          console.error(`AhaSend error ${err.status} (request ${err.requestId})`);
          return Response.json({ error: "Failed to send email" }, { status: 502 });
        }
        throw err;
      }
    }

    return new Response("Not found", { status: 404 });
  },
};
```

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.

<Warning>
  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.
</Warning>

## 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:

```ts src/index.ts theme={null}
import {
  WebhookVerifier,
  isKnownWebhookEvent,
  nextRouteHandler,
} from "@ahasend/sdk/webhooks";

// inside the same fetch handler, above the final 404 return:
if (request.method === "POST" && url.pathname === "/webhooks/ahasend") {
  const verifier = new WebhookVerifier(env.AHASEND_WEBHOOK_SECRET);

  return nextRouteHandler(
    verifier,
    async (event, verifiedRequest) => {
      const webhookId = verifiedRequest.headers.get("webhook-id");
      if (isKnownWebhookEvent(event) && webhookId) {
        console.log("Verified AhaSend webhook", { type: event.type, webhookId });
      }
      return new Response(null, { status: 200 });
    },
    { maxBodyBytes: 1_000_000 },
  )(request);
}
```

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](https://dash.ahasend.com) 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

```bash theme={null}
npx wrangler 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](https://developers.cloudflare.com/queues/) 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](/docs/guides/nodejs-sdk) for client configuration in depth, and the [API reference](/docs/api-reference) for every endpoint the SDK exposes.

## Troubleshooting

<AccordionGroup>
  <Accordion title="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`.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>
