Skip to main content
The webhook route is where Express 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.

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 with a verified sending domain
  • An API key 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

Configure Environment Variables

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

Create the Client

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

Send an Email from an Express Route

server.ts
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, so a transient 5xx does not become two emails. 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). Add sandbox: true to the send request to validate it 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.
server.ts
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 pointing at https://your-app.com/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix).
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

  • 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: new Date(Date.now() + 60 * 60 * 1000).toISOString() } to defer delivery by an hour. The first attempt must be in the future and within seven days of the request.
  • Your own idempotency keys: pass { idempotencyKey: "order-123" } as the second argument to send() to dedupe against your own identifiers, reusing each key only with the same payload.
  • Attachments: for binary files such as PDFs, base64-encode the bytes yourself, use the encoded string as data, and set base64: true. The flag tells AhaSend how to decode data; it does not perform the encoding.
See the API reference for every endpoint the SDK exposes. On a different Node framework? The same SDK powers the Fastify and NestJS guides.

Troubleshooting

The API key is missing, malformed, or revoked. Verify AHASEND_API_KEY is loaded and that the key exists in your dashboard. Do not print any part of the key while troubleshooting.
A body parser ran before the adapter. The adapter treats this as a setup error and passes it to next, so it surfaces through your error middleware rather than as a signature failure. The fix is to stop parsing that route: register it before any global express.json(), or scope the parser to your other routes. Don’t add express.raw(): the adapter reads the raw stream itself, and a parser in front of it will always break verification.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.