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

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

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

## 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/-/settings/api-keys) with the `messages:send:all` scope, and your account ID

## Install the SDK

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

`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({
        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: `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.

Add `sandbox: true` to the send request to validate it 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: 1_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: 1_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

* **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` to a future RFC 3339 timestamp within 7 days of the request.
* **Your own idempotency keys**: pass `{ idempotencyKey: "order-123" }` as the second argument to `send()` so stored results for the same request can be replayed within the server's retention window.
* **Attachments**: pass `attachments: [{ data, content_type, file_name, base64: true }]`. Set `base64: true` for binary files such as PDFs.

See the [API reference](/docs/api-reference) for every endpoint the SDK exposes. On a different Node framework? The same SDK powers the [Express](/docs/guides/express) and [Hono](/docs/guides/hono) guides.

## Troubleshooting

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

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

  <Accordion title="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/-/settings/api-keys).
  </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>
