Skip to main content
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 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. See send-only credentials and authentication. Keep credentials on the server. Use sandbox mode 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 and retry policy.

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:{your-domain} scope (messages:send:all for sending from many domains), and your account ID

Install the SDK

Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a verified sender, a send-only API key, 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:
quick-send.mjs
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):
.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. 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 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

For substitutions, attachments and scheduled sends, use the sending guide. The Node.js SDK and Go SDK references explain client options, per-recipient results and retries. Use idempotency rules when your application retries a business operation.

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.