> ## 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 with Express (Node.js)

> Send transactional email from an Express 5 app with the AhaSend Node.js SDK, and verify signed delivery webhooks with the SDK's built-in Express adapter.

This guide shows how to use AhaSend to send transactional email from Express in Node.js, keep credentials on the server, and test a sandbox send.

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.

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

* 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/-/transactional/credentials) 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

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

## 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 the server project directory, add `.env` to `.gitignore`, then create `.env` with `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_FROM` (an address on your verified domain). Keep these values out of browser code and AI prompts. Use Node.js 22 or newer. Save this as `quick-send.mjs`:

```js quick-send.mjs theme={null}
import { AhaSendClient } from "@ahasend/sdk";
try {
  const client = AhaSendClient.fromEnv();
  const result = await client.messages.send({
    from: { email: process.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((entry) => entry.status);
  console.log({ statuses });
  if (!statuses.length || statuses.some((s) => !["queued", "scheduled"].includes(s)))
    throw new Error("A recipient was rejected");
} catch {
  console.error("Sandbox send failed; check your credentials, sender and account.");
  process.exitCode = 1;
}
```

Run `node --env-file=.env quick-send.mjs` from that directory. The output lists each recipient status; `queued` or `scheduled` means accepted for sandbox processing. The process exits with a failure if the request or any recipient fails. Continue below for the full integration.

## 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 (part 1 of 2) 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(
        {
          sandbox: true,
          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: `sandbox-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, but a 5xx releases the server record and a retry can send twice. 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).

The send example uses `sandbox: true` to validate the request 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 (part 2 of 2) 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: 30_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.

## 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] 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/-/transactional/credentials). Do not print any part of the key while troubleshooting.

> [accordion] The webhook route errors out with an already-parsed body
>
> A JSON parser converted the signed bytes into an object before the adapter ran. The adapter treats that object as a setup error and passes it to `next`, so it surfaces through your error middleware. Register the webhook route before global `express.json()`, or scope JSON parsing to your other routes. The adapter can also use a Buffer preserved by `express.raw()`; keep those bytes unchanged and set a matching body-size limit.

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