Skip to main content
This guide shows how to use AhaSend to send transactional email from Koa in Node.js, keep credentials on the server, and test a sandbox send. Koa ships without a router or a body parser, so this guide adds @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 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. 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
Keep these values in your deployment platform’s secret store. The examples require an explicit 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
A 202 is a multi-status response: 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 generic WebhookVerifier. 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
The webhook router is mounted before 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

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 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().
bodyParser() must be registered before the router that reads parsed bodies. Check middleware order and that the client sends Content-Type: application/json.
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.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.