Skip to main content
Fastify 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 with a verified sending domain
  • An API key with the messages:send:all scope, and your account ID

Install the SDK

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

Create the Client

Create the client once at module scope and reuse it across requests:
lib/ahasend.ts

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:
server.ts
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 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.
server.ts
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 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 for every endpoint the SDK exposes. On a different Node framework? The same SDK powers the Express and Hono guides.

Troubleshooting

The adapter couldn’t find a raw body. Confirm fastify-raw-body is registered — and awaited — 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).
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.
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.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.