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

> Send transactional email from a Fastify 5 app with the AhaSend Node.js SDK, and verify signed webhooks using fastify-raw-body and the Fastify adapter.

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

[Fastify](https://fastify.dev) parses JSON before your handler sees it, so the webhook route needs `fastify-raw-body` registered on it. Signature verification runs against the raw bytes, and the adapter returns an opaque 400 without them.

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 Fastify 5. Set `"type": "module"` in `package.json` — the code below uses top-level `await` and ESM import specifiers.
* 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:{your-domain}` scope (`messages:send:all` for sending from many domains), and your account ID

## Install the SDK

```bash npm theme={null}
npm install @ahasend/sdk fastify @fastify/rate-limit fastify-raw-body
```

```bash pnpm theme={null}
pnpm add @ahasend/sdk fastify @fastify/rate-limit fastify-raw-body
```

```bash yarn theme={null}
yarn add @ahasend/sdk fastify @fastify/rate-limit fastify-raw-body
```

```bash bun theme={null}
bun add @ahasend/sdk fastify @fastify/rate-limit fastify-raw-body
```

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

`fastify-raw-body` is only needed for webhooks: signature verification requires the raw, unparsed request body.

## Configure Environment Variables

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

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
WELCOME_ROUTE_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";

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

export const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});

export const welcomeRouteToken = requireEnv("WELCOME_ROUTE_TOKEN");
```

## Send an Email from a Fastify Route

Fastify parses JSON bodies out of the box, so no body-parsing plugin is needed here. A send route spends your AhaSend quota and puts caller-supplied text into mail you sign, so it authenticates the caller, validates the body against a schema, caps the body size, and rate-limits before it reaches the SDK:

```ts server.ts theme={null}
import Fastify from "fastify";
import rateLimit from "@fastify/rate-limit";
import { timingSafeEqual } from "node:crypto";
import { AhaSendAPIError, isAhaSendError } from "@ahasend/sdk";
import { ahasend, welcomeRouteToken } from "./lib/ahasend.js";

const app = Fastify({ logger: true });

// `global: false` keeps the limiter off the webhook route, where a 429 would
// only make AhaSend retry.
await app.register(rateLimit, { global: false });

type WelcomeBody = { email: string; name?: string; eventId: string };

const welcomeBodySchema = {
  type: "object",
  additionalProperties: false,
  required: ["email", "eventId"],
  properties: {
    email: { type: "string", format: "email", maxLength: 320 },
    name: { type: "string", maxLength: 100 },
    eventId: { type: "string", minLength: 1, maxLength: 200, pattern: "^[A-Za-z0-9._:-]+$" },
  },
} as const;

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

app.post<{ Body: WelcomeBody }>(
  "/api/welcome",
  {
    bodyLimit: 16_384,
    schema: { body: welcomeBodySchema },
    config: { rateLimit: { max: 10, timeWindow: "1 minute" } },
  },
  async (request, reply) => {
    if (!isAuthorized(request.headers.authorization)) {
      return reply.code(401).send({ error: "Unauthorized" });
    }

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

      const rejected = result.data.filter((r) => r.status === "error");
      if (rejected.length > 0) {
        request.log.warn({ count: rejected.length }, "Recipients rejected");
        return reply.code(502).send({ error: "Recipient was not accepted" });
      }

      return reply.code(202).send({ accepted: true });
    } catch (err) {
      // Timeouts and connection failures are not `AhaSendAPIError`s. Catching
      // only that class lets them reach Fastify's default handler, which
      // echoes `err.message` — which can carry your account ID and the
      // upstream URL — straight back to the caller in a 500 body.
      if (!isAhaSendError(err)) throw err;
      request.log.error(
        {
          errorCode: err.code,
          status: err instanceof AhaSendAPIError ? err.status : undefined,
          requestId: err instanceof AhaSendAPIError ? err.requestId : undefined,
        },
        "AhaSend send failed",
      );
      return reply.code(502).send({ error: "Failed to send email" });
    }
  },
);
```

Start the server with `app.listen()` at the very **end** of the file, after the webhook route below is declared. Fastify refuses new plugins and routes once `listen()` has resolved, so anything registered after it throws `AVV_ERR_ROOT_PLG_BOOTED` or `FST_ERR_INSTANCE_ALREADY_LISTENING`.

Keep `WELCOME_ROUTE_TOKEN` server-side and require your application authentication before accepting a recipient address. Serve the route only over HTTPS, terminating TLS at Fastify or a trusted reverse proxy. `@fastify/rate-limit` keys on `request.ip` and counts in memory per process, so behind a load balancer set Fastify's `trustProxy` and give it a shared store; otherwise every client shares one bucket and each instance counts separately. `eventId` must be a stable identifier for the same welcome-email action; reuse it only with the same payload.

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 SDK retries transient failures automatically, but a 5xx or timeout does not prove that no email was sent. The stable `idempotencyKey` lets stored results be replayed for 24 hours, but server errors release the key for re-execution. Reconcile an uncertain result before retrying, and persist `eventId` in your application if the welcome email must never be sent twice.

The send example uses `sandbox: true` to validate the request without delivering anything. The idempotency key is bound to a hash of the request body, so flipping `sandbox` on or off while reusing the same `eventId` returns `422` `AhaSendIdempotencyMismatchError` — use a distinct key prefix for sandbox traffic.

## Handle Webhooks

The SDK ships a dedicated Fastify adapter that verifies the HMAC signature and hands you a typed event. The verifier needs the **raw request body**, which Fastify normally discards after JSON parsing. Register the [`fastify-raw-body`](https://github.com/Eomm/fastify-raw-body) plugin and enable it only on the webhook route.

This continues the same `server.ts`, appended after the send route. `await` the `register` call: it installs an `onRoute` hook that only applies to routes declared after the plugin has finished loading, so an unawaited registration leaves `request.rawBody` undefined and the route answers `400` forever.

```ts server.ts theme={null}
import rawBody from "fastify-raw-body";
import {
  WebhookVerifier,
  fastifyWebhookHandler,
  isKnownWebhookEvent,
} from "@ahasend/sdk/webhooks";

await app.register(rawBody, {
  field: "rawBody",
  global: false,
  encoding: false,
  runFirst: true,
});

const webhookSecret = process.env.AHASEND_WEBHOOK_SECRET;
if (!webhookSecret) {
  throw new Error("Missing required environment variable: AHASEND_WEBHOOK_SECRET");
}
const verifier = new WebhookVerifier(webhookSecret);

app.post(
  "/webhooks/ahasend",
  { bodyLimit: 30_000_000, config: { rawBody: true } },
  fastifyWebhookHandler(verifier, async (event) => {
    if (!isKnownWebhookEvent(event)) return; // future event type, acknowledge & ignore

    switch (event.type) {
      case "message.delivered":
        app.log.info({ webhookType: event.type });
        break;
      case "message.bounced":
        app.log.info({ webhookType: event.type });
        break;
      case "message.opened":
        app.log.info({ webhookType: event.type });
        break;
    }
  }, { maxBodyBytes: 30_000_000 }),
);

// Last statement in the file: no plugin or route can be added after this.
await app.listen({
  port: Number(process.env.PORT ?? 3000),
  host: "0.0.0.0",
});
```

With `global: false`, the raw-body capture hook only runs on routes that opt in via `config: { rawBody: true }`. Note that `encoding: false` also swaps the JSON body parser app-wide — your other routes still get JSON in `request.body`, they just get it from the plugin's parser. The route `bodyLimit` bounds the raw capture as well as the parse, and `maxBodyBytes` bounds what the verifier will hash, so an oversized body is rejected before either reads it all.

The adapter replies `200` automatically when your handler completes, `400` if verification fails or the raw body is missing, and `413` if the body exceeds `maxBodyBytes`. If your handler throws, the adapter rethrows so Fastify answers `500` and AhaSend retries the delivery.

Timestamp validation does not stop a valid delivery from being replayed inside the accepted window. Before adding side effects, atomically store the `webhook-id` header with durable work and return `200` for an ID you have already accepted. Do not log recipients, message content, request bodies, signatures, or secrets.

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

## 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] Webhook route always returns 400
>
> The adapter couldn't find a raw body. Confirm `fastify-raw-body` is registered — and `await`ed — **before** the webhook route is declared, that the route options include `config: { rawBody: true }`, and that the plugin was registered with `field: "rawBody"` (the adapter reads `request.rawBody`).

> [accordion] AVV\_ERR\_ROOT\_PLG\_BOOTED or FST\_ERR\_INSTANCE\_ALREADY\_LISTENING
>
> A plugin or route was added after `app.listen()` resolved. Fastify seals the instance once it starts, so `app.listen()` must be the last statement in `server.ts`, after both routes are declared.

> [accordion] 401 AhaSendAuthenticationError
>
> The API key is missing, malformed, or revoked. Verify `AHASEND_API_KEY` is loaded without logging any part of it, and that the key exists in your [dashboard](https://dash.ahasend.com/account/-/transactional/credentials).

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