> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# How to Send Email with Koa (Node.js)

> Send transactional email from a Koa app with the AhaSend Node.js SDK: install, send from a route, and verify AhaSend webhooks with raw-body.

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](https://koajs.com) 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](https://ahasend.com/docs/api-reference/scopes.md). See [send-only credentials](https://ahasend.com/docs/send-api/credentials.md) and [authentication](https://ahasend.com/docs/api-reference/authentication.md).

Keep credentials on the server. Use [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) 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](https://ahasend.com/docs/api-reference/webhooks/security.md) and [retry policy](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

## Prerequisites

* Node.js 22 or newer, the AhaSend SDK's minimum
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://ahasend.com/docs/send-api/credentials.md) with the domain-specific `messages:send:{your-domain}` scope, and your account ID

## Install the SDK

```bash npm theme={null}
npm install @ahasend/sdk koa @koa/router @koa/bodyparser raw-body
npm install --save-dev typescript @types/koa @types/node
```

```bash pnpm theme={null}
pnpm add @ahasend/sdk koa @koa/router @koa/bodyparser raw-body
pnpm add --save-dev typescript @types/koa @types/node
```

```bash yarn theme={null}
yarn add @ahasend/sdk koa @koa/router @koa/bodyparser raw-body
yarn add --dev typescript @types/koa @types/node
```

```bash bun theme={null}
bun add @ahasend/sdk koa @koa/router @koa/bodyparser raw-body
bun add --dev typescript @types/koa @types/node
```

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](https://ahasend.com/docs/domains.md), a [send-only API key](https://ahasend.com/docs/send-api/credentials.md), 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`:

```js quick-send.mjs theme={null}
import { AhaSendClient } from "@ahasend/sdk";
try {
  const client = AhaSendClient.fromEnv();
  const result = await client.messages.send({
    from: { email: process.env.AHASEND_FROM },
    recipients: [{ email: "recipient@example.com" }],
    subject: "My first sandbox email",
    text_content: "Hello from AhaSend",
    sandbox: true,
  });
  const statuses = result.data.map((entry) => entry.status);
  console.log({ statuses });
  if (!statuses.length || statuses.some((s) => !["queued", "scheduled"].includes(s)))
    throw new Error("A recipient was rejected");
} catch {
  console.error("Sandbox send failed; check your credentials, sender and account.");
  process.exitCode = 1;
}
```

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`):

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
AHASEND_DELIVERY_MODE=sandbox
WELCOME_ENDPOINT_TOKEN=a-long-random-server-to-server-token
```

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:

```ts lib/ahasend.ts theme={null}
import { AhaSendClient } from "@ahasend/sdk";

export function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

export const deliveryMode = requireEnv("AHASEND_DELIVERY_MODE");
if (deliveryMode !== "sandbox" && deliveryMode !== "live") {
  throw new Error("AHASEND_DELIVERY_MODE must be sandbox or live");
}

export const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});
export const sandboxMode = deliveryMode === "sandbox";
export const welcomeToken = requireEnv("WELCOME_ENDPOINT_TOKEN");
```

## Send an Email from a Koa Route

```ts server.ts theme={null}
import { createHash, timingSafeEqual } from "node:crypto";
import Koa from "koa";
import type { Middleware } from "koa";
import Router from "@koa/router";
import { bodyParser } from "@koa/bodyparser";
import { AhaSendAPIError } from "@ahasend/sdk";
import { createAhaSendWebhookRouter, memoryWebhookStore } from "./lib/webhook.js";
import { ahasend, deliveryMode, sandboxMode, welcomeToken, requireEnv } from "./lib/ahasend.js";

const app = new Koa();
const router = new Router();
const expectedTokenHash = createHash("sha256").update(welcomeToken).digest();
const uuidPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const requireWelcomeToken: Middleware = async (ctx, next) => {
  const authorization = ctx.get("authorization");
  const provided = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
  const providedHash = createHash("sha256").update(provided).digest();
  if (!provided || !timingSafeEqual(providedHash, expectedTokenHash)) {
    ctx.status = 401;
    return;
  }
  await next();
};

router.post("/api/welcome", requireWelcomeToken, async (ctx) => {
  const input = ctx.request.body as Record<string, unknown>;
  const eventID = input?.event_id;
  const email = input?.email;
  const name = input?.name;
  if (
    typeof eventID !== "string" ||
    !uuidPattern.test(eventID) ||
    typeof email !== "string" ||
    email.length > 320 ||
    !emailPattern.test(email) ||
    (name !== undefined && (typeof name !== "string" || name.length > 200))
  ) {
    ctx.status = 400;
    ctx.body = { error: "Invalid request" };
    return;
  }

  try {
    const result = await ahasend.messages.send({
      from: { email: "hello@yourdomain.com", name: "Your App" },
      recipients: [{ email, name }],
      subject: "Welcome to Your App!",
      html_content: "<h1>Welcome aboard 🎉</h1><p>We're glad you're here.</p>",
      text_content: "Welcome aboard! We're glad you're here.",
      sandbox: sandboxMode,
    }, { idempotencyKey: `welcome-${deliveryMode}-${eventID}` });

    const accepted = result.data.filter((entry) =>
      entry.status === "queued" || entry.status === "scheduled"
    );
    const rejected = result.data.length - accepted.length;
    if (rejected > 0 || accepted.length === 0) {
      console.error("AhaSend rejected recipients", { count: rejected });
      ctx.status = 502;
      ctx.body = { error: "Email was not accepted" };
      return;
    }

    ctx.status = 202;
    ctx.body = { queued: accepted.length };
  } catch (err) {
    if (err instanceof AhaSendAPIError) {
      console.error("AhaSend request failed", {
        status: err.status,
        code: err.code,
        requestId: err.requestId,
      });
      ctx.status = 502;
      ctx.body = { error: "Failed to send email" };
      return;
    }
    throw err;
  }
});

app.on("error", () => console.error("Unhandled request error"));
const webhookRouter = createAhaSendWebhookRouter(requireEnv("AHASEND_WEBHOOK_SECRET"), memoryWebhookStore);
app.use(webhookRouter.routes()).use(webhookRouter.allowedMethods());
app.use(bodyParser({ enableTypes: ["json"], jsonLimit: "64kb" }));
app.use(router.routes()).use(router.allowedMethods());

const server = app.listen(Number(process.env.PORT ?? 3000));
server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.keepAliveTimeout = 5_000;

for (const signal of ["SIGINT", "SIGTERM"] as const) {
  process.once(signal, () => {
    server.close((error) => {
      if (error) process.exitCode = 1;
    });
  });
}
```

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`](https://github.com/stream-utils/raw-body) package. Timestamp validation rejects stale signatures but does not deduplicate a valid delivery replayed inside the tolerance window.

```ts lib/webhook.ts theme={null}
import Router from "@koa/router";
import getRawBody from "raw-body";
import {
  AhaSendWebhookVerificationError,
  type AnyWebhookEvent,
  WebhookVerifier,
  isKnownWebhookEvent,
} from "@ahasend/sdk/webhooks";

interface WebhookStore {
  // Atomically commit webhookId and durable work for event.
  // Return false when that ID was already committed.
  enqueueOnce(webhookId: string, event: AnyWebhookEvent): Promise<boolean>;
}

const WEBHOOK_BODY_LIMIT_BYTES = 30_000_000;

export function createAhaSendWebhookRouter(secret: string, store: WebhookStore): Router {
  const verifier = new WebhookVerifier(secret);
  const webhookRouter = new Router();

  webhookRouter.post("/webhooks/ahasend", async (ctx) => {
    let raw: Buffer;
    try {
      raw = await getRawBody(ctx.req, { limit: WEBHOOK_BODY_LIMIT_BYTES });
    } catch (error) {
      // An aborted read leaves the rest of the body on the socket, which
      // would corrupt the next request on a keep-alive connection.
      ctx.set("connection", "close");
      ctx.status =
        typeof error === "object" && error !== null && "status" in error && error.status === 413
          ? 413
          : 400;
      return;
    }

    try {
      const event = await verifier.parse(ctx.req.headers, raw);
      if (!isKnownWebhookEvent(event)) {
        ctx.status = 204;
        return;
      }

      const webhookID = ctx.get("webhook-id");
      const accepted = await store.enqueueOnce(webhookID, event);
      ctx.status = accepted ? 202 : 200;
    } catch (error) {
      if (error instanceof AhaSendWebhookVerificationError) {
        ctx.status = error.reason === "body_too_large" ? 413 : 400;
        return;
      }
      throw error;
    }
  });

  return webhookRouter;
}

// Local demo only. Replace with a durable store before adding side effects.
const events = new Map<string, AnyWebhookEvent>();
export const memoryWebhookStore: WebhookStore = {
  async enqueueOnce(id, event) {
    if (events.has(id)) return false;
    events.set(id, event);
    return true;
  },
};
```

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](https://dash.ahasend.com) 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](https://ahasend.com/docs/send-api/send-email.md). The [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md) references explain client options, per-recipient results and retries. Use [idempotency rules](https://ahasend.com/docs/api-reference/idempotency.md) when your application retries a business operation.

## Troubleshooting

> [accordion] 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.

> [accordion] 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()`.

> [accordion] 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`.

> [accordion] 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](https://ahasend.com/docs/send-api/credentials.md) without printing any part of the key.

> [accordion] 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](https://ahasend.com/docs/domains.md) and [create a send-only key](https://ahasend.com/docs/send-api/credentials.md).
* Other ways to send: [REST API](https://ahasend.com/docs/send-api/send-email.md), [SMTP](https://ahasend.com/docs/smtp.md), [CLI quickstart](https://ahasend.com/docs/cli/quickstart.md), [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md).
* Request rules: [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [scopes](https://ahasend.com/docs/api-reference/scopes.md), [idempotency](https://ahasend.com/docs/api-reference/idempotency.md), [errors](https://ahasend.com/docs/api-reference/errors.md) and [rate limits](https://ahasend.com/docs/api-reference/rate-limits.md).
* Testing and events: [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md), [CLI webhook testing](https://ahasend.com/docs/cli/webhook-testing.md), [event payloads](https://ahasend.com/docs/api-reference/webhooks.md), [signature verification](https://ahasend.com/docs/api-reference/webhooks/security.md) and [delivery retries](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).
* Data and limits: [retention](https://ahasend.com/docs/retention.md), [tracking](https://ahasend.com/docs/tracking/open-tracking.md) and [plans and feature availability](https://ahasend.com/docs/facts.md#plans-and-features).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.