@koa/router and @koa/bodyparser. The webhook route has to read the raw, unparsed bytes, since a parsed body no longer matches the signature.
Prerequisites
- Node.js 22 or newer, which both the AhaSend SDK and
raw-bodyrequire - 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
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.
server.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. One megabyte covers every outbound message.* event; raise it — up to the verifier’s own 30,000,000-byte ceiling, above which parse() throws body_too_large — only if you receive inbound message.routing events carrying attachments, and 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.
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
- Templating: pass
substitutionsper recipient and use{{ variable }}in the subject or body. - Batch sends:
recipientsaccepts up to 100 entries; each gets a separate, individually-substituted message. - Scheduling: set
schedule.first_attemptto an RFC 3339 timestamp to defer delivery. It must be in the future and within seven days of the request. - Your own idempotency keys: pass
{ idempotencyKey: "order-123" }as the second argument tosend()to dedupe against your own identifiers. - Attachments: pass
attachments: [{ data, content_type, file_name, base64: true }]. For binary files such as PDFs, base64-encode the bytes yourself and pass the encoded string asdata;base64: truetells AhaSend how to decodedata, it does not perform the encoding.
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 100 consecutive failures.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.
