expressWebhookHandler directly, with no express.raw() in front of it and no global JSON parser on that path.
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 Express 5. The code below relies on Express 5 forwarding a rejected promise from an
asynchandler to your error middleware; on Express 4 the samethrowbecomes 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 (ormessages:send:allif it must cover multiple 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 withsandbox: 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
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.
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 (part 1 of 2)
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, but a 5xx releases the server record and a retry can send twice. 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).
The send example uses sandbox: true to validate the request 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. MountexpressWebhookHandler 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 (part 2 of 2)
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).
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
401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
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.The webhook route errors out with an already-parsed body
The webhook route errors out with an already-parsed body
A JSON parser converted the signed bytes into an object before the adapter ran. The adapter treats that object as a setup error and passes it to
next, so it surfaces through your error middleware. Register the webhook route before global express.json(), or scope JSON parsing to your other routes. The adapter can also use a Buffer preserved by express.raw(); keep those bytes unchanged and set a matching body-size limit.400 error mentioning the from address
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 and create a send-only key.
- Other ways to send: REST API, SMTP, CLI quickstart, Node.js SDK and Go SDK.
- Request rules: API authentication, scopes, idempotency, errors and rate limits.
- Testing and events: sandbox mode, CLI webhook testing, event payloads, signature verification and delivery retries.
- Data and limits: retention, tracking and plans and feature availability.

