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

> Send transactional email from Express in under 5 minutes with the AhaSend Node.js SDK, plus signed webhook handling via the built-in Express adapter.

The webhook route is where [Express](https://expressjs.com) apps usually go wrong: signature verification needs the raw request body, so mount `expressWebhookHandler` directly, with no `express.raw()` in front of it and no global JSON parser on that path.

## Prerequisites

* Node.js 22 or newer, and Express 5. The code below relies on Express 5 forwarding a rejected promise from an `async` handler to your error middleware; on Express 4 the same `throw` becomes an unhandled rejection and the request never completes.
* 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) with the `messages:send:{domain}` scope for your sending domain (or `messages:send:all` if it must cover multiple domains), and your account ID

## Install the SDK

<CodeGroup>
  ```bash npm theme={null}
  npm install @ahasend/sdk express
  npm install --save-dev typescript @types/express @types/node
  ```

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk express
  pnpm add --save-dev typescript @types/express @types/node
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk express
  yarn add --dev typescript @types/express @types/node
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk express
  bun add --dev typescript @types/express @types/node
  ```
</CodeGroup>

## Configure Environment Variables

Add your credentials to `.env` (and load them with `node --env-file=.env` or `dotenv`):

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
WELCOME_ENDPOINT_TOKEN=replace-with-a-long-random-value
```

## Create the Client

Create the client once at module scope and reuse it across requests:

```ts lib/ahasend.ts theme={null}
import { AhaSendClient } from "@ahasend/sdk";

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

## Send an Email from an Express Route

```ts server.ts theme={null}
import express, { type ErrorRequestHandler } from "express";
import { timingSafeEqual } from "node:crypto";
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend } from "./lib/ahasend.js";

const app = express();
const welcomeEndpointToken = process.env.WELCOME_ENDPOINT_TOKEN;
if (!welcomeEndpointToken) throw new Error("WELCOME_ENDPOINT_TOKEN is required");
const expectedAuthorization = Buffer.from(`Bearer ${welcomeEndpointToken}`);

function isAuthorized(header: string | undefined): boolean {
  const supplied = Buffer.from(header ?? "");
  return (
    supplied.length === expectedAuthorization.length &&
    timingSafeEqual(supplied, expectedAuthorization)
  );
}

app.post(
  "/api/welcome",
  (req, res, next) => {
    if (!isAuthorized(req.get("authorization"))) {
      res.status(401).json({ error: "Unauthorized" });
      return;
    }
    next();
  },
  express.json({ limit: "16kb" }),
  async (req, res) => {
    const { email, name, eventId } = req.body ?? {};
    if (
      typeof email !== "string" ||
      email.length < 3 ||
      email.length > 320 ||
      (name !== undefined && (typeof name !== "string" || name.length > 100)) ||
      typeof eventId !== "string" ||
      !/^[A-Za-z0-9._:-]{1,200}$/.test(eventId)
    ) {
      res.status(400).json({ error: "Invalid request body" });
      return;
    }

    try {
      const result = await ahasend.messages.send(
        {
          from: { email: "hello@yourdomain.com", name: "Your App" },
          recipients: [{ email, 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.",
        },
        { idempotencyKey: `welcome-${eventId}` },
      );

      const rejected = result.data.filter((r) => r.status === "error");
      if (rejected.length > 0) {
        console.warn(`${rejected.length} recipient(s) rejected`);
        res.status(502).json({ error: "Recipient was not accepted" });
        return;
      }

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

const opaqueErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
  if (res.headersSent) {
    next(err);
    return;
  }

  const candidate =
    typeof err === "object" && err !== null && "status" in err
      ? (err as { status?: unknown }).status
      : undefined;
  const status = candidate === 400 || candidate === 413 ? candidate : 500;
  console.error("Request failed");
  res.status(status).json({ error: status === 500 ? "Internal server error" : "Invalid request" });
};
```

Call this route only from trusted server-side code with `Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>`. Serve it only over HTTPS. For a user-facing endpoint, replace the token with your application's authentication and authorization, validate addresses according to your product's rules, and rate-limit sends. The route-specific JSON parser authenticates before reading the body and leaves the webhook stream untouched.

`eventId` must be a stable identifier for the same welcome-email action — the caller sends the same one when it retries, and a new one for a genuinely new send.

AhaSend's 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 rather than treating a resolved promise as full success.

The SDK generates an `Idempotency-Key` for every `send()` and reuses it across its own internal retries, so a transient 5xx does not become two emails. It cannot cover a retry your *caller* makes — and a timeout never proves the send did not land — which is what the stable `idempotencyKey` above is for: a stored result is replayed for 24 hours, while a server error releases the key for re-execution. Reuse a key only with an identical request body — the same key with different content is rejected as `AhaSendIdempotencyMismatchError` (HTTP 422).

Add `sandbox: true` to the send request to validate it without delivering anything. It changes the request body, so give a sandbox send a different `idempotencyKey` from the live send it stands in for.

## Handle Webhooks

The SDK ships a dedicated Express adapter that verifies the HMAC signature and timestamp, then hands you a typed event. Mount `expressWebhookHandler` **directly** on the route: it reads and size-bounds the raw request stream itself, so you don't need `express.raw()` (or any other body parser) in front of it. Do not put a global `express.json()` middleware before this route, or the body will already be parsed by the time the adapter runs.

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

const verifier = new WebhookVerifier(process.env.AHASEND_WEBHOOK_SECRET!);

// No body parser is mounted on this path.
app.post(
  "/webhooks/ahasend",
  expressWebhookHandler(verifier, async (event) => {
    if (!isKnownWebhookEvent(event)) return; // future event type, acknowledge & ignore

    switch (event.type) {
      case "message.delivered":
        console.log("Message delivered");
        break;
      case "message.bounced":
        console.log("Message bounced");
        break;
      case "message.opened":
        console.log("Message opened");
        break;
    }
  }, { maxBodyBytes: 1_000_000 }),
);

// Error middleware must be registered after every route.
app.use(opaqueErrorHandler);
app.listen(3000);
```

The adapter sends an empty `200` after the handler completes, an empty `400` for invalid signatures or payloads, and an empty `413` above `maxBodyBytes`. Set your reverse proxy's body limit to the same value or lower, and serve the webhook only over HTTPS.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-app.com/webhooks/ahasend`, and copy its secret into `AHASEND_WEBHOOK_SECRET` exactly as shown (including the `aha-whsec-` prefix).

<Warning>
  Timestamp verification is not replay deduplication. Before adding side effects, atomically persist the verified `webhook-id` header together with durable queue/outbox work. Acknowledge an already-recorded ID without processing it again, and make the worker idempotent. As you replace the `console.log` calls with real handling, keep `event.data` out of your logs: it carries the recipient address, sender, and subject, plus the opener's IP and user agent on open and click events.
</Warning>

## Going Further

* **Templating**: pass `substitutions` per recipient and use `{{ variable }}` in the subject or body.
* **Batch sends**: `recipients` accepts up to 100 entries; each gets a separate, individually-substituted message.
* **Scheduling**: set `schedule: { first_attempt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }` to defer delivery by an hour. The first attempt must be in the future and within seven days of the request.
* **Your own idempotency keys**: pass `{ idempotencyKey: "order-123" }` as the second argument to `send()` to dedupe against your own identifiers, reusing each key only with the same payload.
* **Attachments**: for binary files such as PDFs, base64-encode the bytes yourself, use the encoded string as `data`, and set `base64: true`. The flag tells AhaSend how to decode `data`; it does not perform the encoding.

See the [API reference](/docs/api-reference) for every endpoint the SDK exposes. On a different Node framework? The same SDK powers the [Fastify](/docs/guides/fastify) and [NestJS](/docs/guides/nestjs) guides.

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 AhaSendAuthenticationError">
    The API key is missing, malformed, or revoked. Verify `AHASEND_API_KEY` is loaded and that the key exists in your [dashboard](https://dash.ahasend.com/account/-/settings/api-keys). Do not print any part of the key while troubleshooting.
  </Accordion>

  <Accordion title="The webhook route errors out with an already-parsed body">
    A body parser ran before the adapter. The adapter treats this as a setup error and passes it to `next`, so it surfaces through your error middleware rather than as a signature failure. The fix is to stop parsing that route: register it before any global `express.json()`, or scope the parser to your other routes. Don't add `express.raw()`: the adapter reads the raw stream itself, and a parser in front of it will always break verification.
  </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>
