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

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# How to Send Email from Cloudflare Workers

> Send transactional email from a Cloudflare Worker with the AhaSend SDK, no nodejs_compat flag needed: Worker secrets, sandbox sends, and signed webhooks.

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](https://ahasend.com/docs/api-reference/scopes.md). See [send-only credentials](https://ahasend.com/docs/send-api/credentials.md) and [authentication](https://ahasend.com/docs/api-reference/authentication.md).

Keep credentials on the server. Use [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) 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](https://ahasend.com/docs/api-reference/webhooks/security.md) and [retry policy](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

## 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/-/transactional/credentials) scoped to `messages:send:{your-domain}`, matching the domain in `from.email`, and your account ID

If you're starting from scratch:

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

## Install the SDK

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

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](https://ahasend.com/docs/domains.md), a [send-only API key](https://ahasend.com/docs/send-api/credentials.md), 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`:

```js quick-worker.js theme={null}
import { AhaSendClient } from "@ahasend/sdk";
export default {
  async fetch(request, env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    try {
      const client = new AhaSendClient({ apiKey: env.AHASEND_API_KEY, accountId: env.AHASEND_ACCOUNT_ID, fetch: fetch.bind(globalThis) });
      const result = await client.messages.send({
        from: { email: env.AHASEND_FROM },
        recipients: [{ email: "recipient@example.com" }],
        subject: "My first sandbox email", text_content: "Hello from AhaSend",
        sandbox: true,
      });
      const statuses = result.data.map((item) => item.status);
      const accepted = statuses.length > 0 && statuses.every((s) => ["queued", "scheduled"].includes(s));
      return Response.json({ statuses }, { status: accepted ? 202 : 422 });
    } catch {
      return new Response("Sandbox send failed", { status: 502 });
    }
  },
};
```

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:

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

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](https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors).

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";

async function tokenMatches(header: string | null, expected: string): Promise<boolean> {
  const supplied = header?.startsWith("Bearer ") ? header.slice(7) : "";
  if (!supplied) return false;
  const hash = (value: string) => crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
  const [a, b] = await Promise.all([hash(supplied), hash(expected)]);
  const left = new Uint8Array(a), right = new Uint8Array(b);
  let difference = 0;
  for (let i = 0; i < left.length; i++) difference |= left[i] ^ right[i];
  return difference === 0;
}

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 ||
        !(await tokenMatches(request.headers.get("authorization"), 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,
        fetch: fetch.bind(globalThis),
      });

      try {
        const sandbox = true; // Set to false only when ready to deliver real mail.
        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.

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

For substitutions, attachments and scheduled sends, use the [sending guide](https://ahasend.com/docs/send-api/send-email.md). The [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md) references explain client options, per-recipient results and retries. Use [idempotency rules](https://ahasend.com/docs/api-reference/idempotency.md) when your application retries a business operation.

## Troubleshooting

> [accordion] 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] 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] 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] 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.

## Related Guides

* Before sending: [verify a domain](https://ahasend.com/docs/domains.md) and [create a send-only key](https://ahasend.com/docs/send-api/credentials.md).
* Other ways to send: [REST API](https://ahasend.com/docs/send-api/send-email.md), [SMTP](https://ahasend.com/docs/smtp.md), [CLI quickstart](https://ahasend.com/docs/cli/quickstart.md), [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md).
* Request rules: [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [scopes](https://ahasend.com/docs/api-reference/scopes.md), [idempotency](https://ahasend.com/docs/api-reference/idempotency.md), [errors](https://ahasend.com/docs/api-reference/errors.md) and [rate limits](https://ahasend.com/docs/api-reference/rate-limits.md).
* Testing and events: [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md), [CLI webhook testing](https://ahasend.com/docs/cli/webhook-testing.md), [event payloads](https://ahasend.com/docs/api-reference/webhooks.md), [signature verification](https://ahasend.com/docs/api-reference/webhooks/security.md) and [delivery retries](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).
* Data and limits: [retention](https://ahasend.com/docs/retention.md), [tracking](https://ahasend.com/docs/tracking/open-tracking.md) and [plans and feature availability](https://ahasend.com/docs/facts.md#plans-and-features).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.