@koa/router and @koa/bodyparser. The webhook route has to read the raw, unparsed bytes, since a parsed body no longer matches the signature.
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, the AhaSend SDK’s minimum
- An AhaSend account with a verified sending domain
- An API key with the domain-specific
messages:send:{your-domain}scope, 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.
raw-body is only needed for webhooks, where signature verification requires the exact, unparsed request bytes. Koa itself ships no type declarations, hence @types/koa; @koa/router and @koa/bodyparser bundle their own, so don’t add @types/koa__router.
Configure Environment Variables
Add your credentials to.env (and load them with node --env-file=.env or dotenv):
.env
sandbox or live delivery mode and refuse to start for any other value.
Create the Client
Create the client once at module scope and reuse it across requests:lib/ahasend.ts
Send an Email from a Koa Route
server.ts
result.data holds one entry per recipient, and an individual recipient can come back with status: "error" and a null id (a suppressed address, for example) while the call itself succeeds. Inspect every entry, not just the first.
This is a server-to-server route protected by a dedicated bearer token. For a browser-facing route, use your application’s session authentication and authorization, load the recipient from server-authoritative storage, and rate-limit sends per authenticated principal so one caller cannot drain your quota. Never expose the token in client-side code or logs.
The stable business-event key protects retries outside the SDK’s internal retry loop. Reuse an idempotency key only for the exact same request payload and never log it: AhaSend matches a key against a hash of the request body, so the same key with different content is rejected as AhaSendIdempotencyMismatchError (HTTP 422). sandbox is part of that body, which is why the key is namespaced by deliveryMode — without that, replaying an event ID after switching modes fails permanently instead of sending. Stored non-server-error outcomes can be replayed for 24 hours, but a 5xx releases the key for re-execution; reconcile an uncertain outcome before another send when duplicates are unacceptable.
Handle Webhooks
There is no Koa-specific adapter, so use the asynchronous genericWebhookVerifier. It needs the raw request body. Put the webhook route in its own router before bodyParser(), then read the exact bytes from the Node request (ctx.req) with the raw-body package. Timestamp validation rejects stale signatures but does not deduplicate a valid delivery replayed inside the tolerance window.
lib/webhook.ts
bodyParser(), and its matched handler never calls next(), so the parser never gets the chance to consume that stream. Order is the whole game here: an app.use() written after app.listen() is silently dropped, and a webhook route mounted behind the parser fails verification on every delivery. Do not start the server if the secret or store initialization fails.
raw-body aborts the read past WEBHOOK_BODY_LIMIT_BYTES, which bounds how much an unauthenticated caller can make you buffer. The example uses the verifier’s 30,000,000-byte ceiling, including for inbound message.routing events with attachments. Keep your reverse proxy’s limit in step. Closing the connection on an aborted read matters: the unread remainder stays queued on the socket, and without Connection: close the next delivery reusing that connection dies with a reset.
The demo map loses data on restart, has no cleanup, and cannot coordinate replicas. A production enqueueOnce must atomically store the verified webhook-id and a durable work/outbox record, retaining the ID for at least the delivery and retry horizon. Process that work idempotently outside the request. A duplicate receives 2xx without enqueuing again; a storage error propagates as 5xx so AhaSend can retry. Do not launch untracked background promises from the request. Apply concurrency limits at the reverse proxy, and never log raw bodies, signatures, whole events/errors, subjects, addresses, or message content.
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
Every webhook delivery returns 400
Every webhook delivery returns 400
The body parser ran before your webhook route and drained the request stream, so
getRawBody(ctx.req) has nothing left to read and fails immediately with stream.not.readable. Register the webhook router before bodyParser() (as above), or configure the parser to skip the webhook path. Also note the raw body must come from ctx.req (the Node request), not ctx.request (Koa’s wrapper). Fix this promptly: AhaSend disables a webhook after more than 100 consecutive failed attempts.The webhook route returns 404
The webhook route returns 404
The
app.use() calls that mount it ran after app.listen(). Koa composes its middleware stack when the server starts, so anything added afterwards is silently ignored. Move every app.use() above app.listen().ctx.request.body is undefined in the send route
ctx.request.body is undefined in the send route
bodyParser() must be registered before the router that reads parsed bodies. Check middleware order and that the client sends Content-Type: application/json.401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
The API key is missing, malformed, revoked, or does not authorize the sending domain. Verify
AHASEND_API_KEY is loaded and review the API credentials guide without printing any part of the key.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.

