> ## 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.

# AhaSend Node.js SDK

> The official Node.js and TypeScript SDK for AhaSend: a typed client, automatic retries with idempotency keys, and signed webhook verification.

[`@ahasend/sdk`](https://www.npmjs.com/package/@ahasend/sdk) is the official Node.js and TypeScript SDK for AhaSend. Typed models, retries that carry an idempotency key, and a separate webhooks entry point that verifies signatures for you.

This page is the SDK reference. To wire it into a specific framework, pick a guide from the sidebar.

## Install

<CodeGroup>
  ```bash npm theme={null}
  npm install @ahasend/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk
  ```
</CodeGroup>

## Runtime Compatibility

| Runtime            | Supported versions                |
| ------------------ | --------------------------------- |
| Node.js            | 22, 24, and 26                    |
| Deno               | Latest 2.x                        |
| Bun                | Latest                            |
| Cloudflare workerd | Supported without `nodejs_compat` |
| Vercel Edge        | Supported                         |

TypeScript declarations ship with the package, so there is no SDK-specific `@types` package to add. Resolve them with `moduleResolution` set to `bundler`, `node16`, or `nodenext`; the package `exports` map is what makes `@ahasend/sdk/webhooks` resolvable. The declarations also name four WHATWG globals a fetch client cannot hide — `fetch`, `Request`, `Response`, and `AbortSignal` — so your project needs either `@types/node` or `"lib": ["DOM"]`. With neither, compilation fails inside `node_modules` rather than in your own code.

<Warning>
  Browsers and browser Service Workers are refused, because the API key grants full send access and anything the browser can read, a visitor can read. `dangerouslyAllowBrowser: true` exists only for browser-shaped server test environments.
</Warning>

## Send Your First Email

Create a [least-privilege API key](/docs/send-api/credentials) with `messages:send:{your-domain}`, and keep the key and account ID in an uncommitted `.env` file or your deployment secret store.

```js send.mjs theme={null}
import { AhaSendClient } from "@ahasend/sdk";

const ahasend = new AhaSendClient({
  apiKey: process.env.AHASEND_API_KEY,
  accountId: process.env.AHASEND_ACCOUNT_ID,
});

const result = await ahasend.messages.send({
  from: { email: "hello@yourdomain.com", name: "Your App" },
  recipients: [{ email: "user@example.com", name: "Jane" }],
  subject: "Welcome to Your App",
  html_content: "<h1>Welcome aboard</h1>",
  text_content: "Welcome aboard",
  sandbox: true, // validates and queues without delivering
});

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 });
  process.exitCode = 1;
} else {
  console.log("Sandbox messages accepted", { count: accepted.length });
}
```

Run it with `node --env-file=.env send.mjs`. Change `sandbox` to `false` only after you have intentionally reviewed the sender, recipients, and production credentials.

Note what the result handling is doing. `recipients` takes up to 100 addresses and each one becomes its own message, so `result.data` holds one entry per recipient. A single recipient can come back `status: "error"` with a null `id`, a suppressed address for instance, while the call itself succeeds. Reading `data[0]` would miss that.

Body fields are snake\_case, matching the API. Beyond the ones above: `reply_to`, `attachments`, `headers`, `substitutions`, `tags`, `tracking`, `retention`, and `schedule`. For one conversation message with visible To and Cc recipients and hidden Bcc recipients, use `messages.sendConversation()` instead.

<Tip>
  `sandbox_result` rehearses a specific outcome: `deliver`, `bounce`, `defer`, `fail`, or `suppress`. Each one fires the matching webhook, so you can test your bounce handling without a real bounce. See [Sandbox Mode](/docs/send-api/sandbox).
</Tip>

## Configure the Client

Build the client once at module scope and share it. Client-level retry, telemetry, idempotency, and local rate-pacing configuration then stays consistent, and any enabled pacing queue is shared by your handlers in that process.

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

export const ahasend = new AhaSendClient({
  apiKey: process.env.AHASEND_API_KEY!,
  accountId: process.env.AHASEND_ACCOUNT_ID!,
});
```

`AhaSendClient.fromEnv()` builds the same client from `AHASEND_API_KEY` (or `AHASEND_TOKEN`) and `AHASEND_ACCOUNT_ID`, along with the other `AHASEND_*` variables. It reads `process.env` by default and accepts any string record instead, which is how runtimes that hand you bindings rather than a process environment — Cloudflare Workers, for one — supply the same variables: `AhaSendClient.fromEnv(env)`.

| Option        | Default                   | Purpose                                                                             |
| ------------- | ------------------------- | ----------------------------------------------------------------------------------- |
| `apiKey`      | required                  | Your API key                                                                        |
| `accountId`   | required                  | Account UUID, one client per account                                                |
| `baseUrl`     | `https://api.ahasend.com` | HTTPS enforced, localhost exempted                                                  |
| `timeoutMs`   | `30_000`                  | Per-attempt timeout in milliseconds                                                 |
| `retry`       | enabled, 3 retries        | Exponential backoff with jitter                                                     |
| `rateLimit`   | disabled                  | Opt-in local request pacing                                                         |
| `idempotency` | auto-generate             | Idempotency key behaviour on create operations                                      |
| `fetch`       | `globalThis.fetch`        | Inject your own fetch implementation                                                |
| `hooks`       | none                      | `onRequest`, `onResponse`, `onRetry`, `onError` telemetry                           |
| `debug`       | `false`                   | Console diagnostics; leave disabled where logs must follow the safe allowlist below |

Every method also takes a trailing options object:

```ts theme={null}
await ahasend.messages.send(body, {
  idempotencyKey: `order-${orderId}`, // drive the key from your own identifier
  timeoutMs: 5_000, // override the per-attempt timeout
  retry: { maxRetries: 1 }, // restrict retries, or false to disable
  signal: AbortSignal.timeout(10_000), // cancel the request
  headers: { "x-trace-id": traceId },
});
```

## Errors

Every non-2xx response throws a typed error:

```ts theme={null}
import { AhaSendAPIError, AhaSendRateLimitError } from "@ahasend/sdk";

try {
  await ahasend.messages.send(body);
} catch (err) {
  if (err instanceof AhaSendRateLimitError) {
    console.warn(`Rate limited, retry after ${err.retryAfterSeconds}s`);
  } else if (err instanceof AhaSendAPIError) {
    console.error(`AhaSend error ${err.status} (request ${err.requestId})`);
  } else {
    throw err;
  }
}
```

`AhaSendError` is the root. `AhaSendAPIError` carries `.status`, `.code`, `.requestId`, and `.body`, and branches into `AhaSendBadRequestError`, `AhaSendAuthenticationError`, `AhaSendPermissionError`, `AhaSendNotFoundError`, `AhaSendConflictError`, `AhaSendIdempotencyConflictError`, `AhaSendUnprocessableEntityError`, `AhaSendIdempotencyMismatchError`, `AhaSendRateLimitError`, and `AhaSendServerError`. Alongside it sit `AhaSendConnectionError` with its `AhaSendTimeoutError` subclass, plus `AhaSendAbortError`, `AhaSendConfigurationError`, `AhaSendRateLimitQueueFullError`, `AhaSendResponseTooLargeError`, and `AhaSendResponseParseError`.

Match on `error.code` or `error.status`, never on message text. Logs, metrics, traces, and exception tags should allowlist only aggregate counts, appropriate opaque IDs, HTTP status, SDK error code, and request ID. Do not serialize whole requests, responses, events, or errors, and do not log `.body`, `.message`, credentials, addresses, content, or idempotency keys.

## Retries and Idempotency

The SDK retries only when the generated operation profile marks the call safe, idempotent, or protected by an idempotency key. For an eligible call it retries `408`, `429`, `5xx`, network failures, timeouts, and an idempotency-in-progress `409` carrying the required replay and retry headers. Caller cancellation and other `4xx` responses are terminal. Create operations declared idempotent receive an automatically generated UUID `Idempotency-Key`, which is reused across the logical call's internal retries.

<Note>
  Stored non-secret outcomes replay for 24 hours, covering 2xx and deterministic 4xx responses. The two API-key create operations are the exception: their replay carries the same one-time `secret_key` for only 5 minutes, so persist that value the moment it arrives. Server errors are not stored, so the same key can execute again after a `5xx`. Pass your own `idempotencyKey` derived from a stable business identifier when your application may retry later, reuse it only with the exact same payload, and reconcile an uncertain result before another send when duplicates are unacceptable.
</Note>

Local rate pacing is separate and off by default. Turn it on with `rateLimit: { enabled: true }`.

## Resources

| Resource                 | Methods                                                                                                                                                              |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client.messages`        | `send`, `sendConversation`, `list`, `iterate`, `get`, `cancel`                                                                                                       |
| `client.domains`         | `list`, `iterate`, `create`, `get`, `update`, `delete`, `checkDns`                                                                                                   |
| `client.apiKeys`         | `list`, `iterate`, `create`, `get`, `update`, `delete`                                                                                                               |
| `client.webhooks`        | `list`, `iterate`, `create`, `get`, `update`, `delete`                                                                                                               |
| `client.statistics`      | `deliverability`, `bounces`, `deliveryTimes`                                                                                                                         |
| `client.suppressions`    | `list`, `iterate`, `create`, `delete`, `wipe`                                                                                                                        |
| `client.routes`          | `list`, `iterate`, `create`, `get`, `update`, `delete`                                                                                                               |
| `client.accounts`        | `get`, `update`, `listMembers`, `addMember`, `removeMember`                                                                                                          |
| `client.smtpCredentials` | `list`, `iterate`, `create`, `get`, `delete`                                                                                                                         |
| `client.subAccounts`     | `list`, `iterate`, `create`, `usage`, `get`, `update`, `delete`, `suspend`, `unsuspend`; nested `apiKeys` has `list`, `iterate`, `create`, `get`, `update`, `delete` |
| `client.ping()`          | Health check                                                                                                                                                         |

List endpoints paginate. Take a page at a time with `list()`, or let `iterate()` walk everything lazily:

```ts theme={null}
for await (const message of ahasend.messages.iterate({ status: "Delivered" })) {
  // pages are fetched as needed
}
```

## Webhooks

`@ahasend/sdk/webhooks` is a separate entry point holding the Standard Webhooks HMAC-SHA256 verifier, typed parsers for all 11 event types, and adapters for Express, Fastify, and Next.js. It does not pull in the API client.

```ts theme={null}
import {
  AhaSendWebhookVerificationError,
  WebhookVerifier,
  isKnownWebhookEvent,
  type AnyWebhookEvent,
  type WebhookHeadersInput,
} from "@ahasend/sdk/webhooks";

const verifier = new WebhookVerifier(process.env.AHASEND_WEBHOOK_SECRET!);

// `rawBody` is the bounded raw read your framework already performed.
export async function handleWebhook(
  headers: WebhookHeadersInput,
  rawBody: Uint8Array,
): Promise<number> {
  let event: AnyWebhookEvent;
  try {
    event = await verifier.parse(headers, rawBody);
  } catch (err) {
    if (!(err instanceof AhaSendWebhookVerificationError)) throw err;
    // Answer with an empty body: `err.reason` names the check that failed and
    // is yours to act on, not something to hand back to the sender.
    return err.reason === "body_too_large" ? 413 : 400;
  }

  if (isKnownWebhookEvent(event)) {
    switch (event.type) {
      case "message.delivered":
        break;
      case "message.bounced":
        break;
    }
  }

  return 202;
}
```

<Warning>
  `parse()` and `verify()` are asynchronous. Await them before treating the body as trusted. The Express, Fastify, and Next.js adapters await verification internally.
</Warning>

Verification needs the **raw request body**, the exact bytes AhaSend sent. If a JSON body parser runs first the signature can no longer be checked: `expressWebhookHandler` hands that to `next`, `fastifyWebhookHandler` answers an opaque `400`, and `nextRouteHandler` reads and bounds the stream itself. Pass the webhook secret exactly as the dashboard shows it, `aha-whsec-` prefix included. Every adapter answers a failed verification with an empty `400`, or `413` when the body was too large, so nothing about the failed check reaches the sender.

Timestamp checking rejects any delivery more than five minutes from your clock, adjustable with `new WebhookVerifier(secret, { toleranceSeconds })`, but it does not deduplicate a valid one replayed inside that window. After verification, atomically commit each `webhook-id` together with durable work or an outbox record. A unique ID insert by itself can lose an event if the process stops before performing the side effect. Acknowledge duplicates without enqueuing again, retry storage failures with a non-`2xx` response, and process the durable work idempotently outside the request.

Direct verification and every adapter enforce a fixed 30,000,000-byte body ceiling. Adapters buffer the whole body before verifying it, and decoding it costs more memory again, so narrow that ceiling to what your payloads actually need: `maxBodyBytes` is a trailing option on `expressWebhookHandler`, `fastifyWebhookHandler`, and `nextRouteHandler`, and it only lowers the fixed limit, never raises it. `WebhookVerifier` takes no such option — on the direct path above, bound the read that produces `rawBody` yourself. Either way, set matching request-size and concurrency limits at the reverse proxy.

## Framework Guides

<CardGroup cols={3}>
  <Card title="Express" icon="server" href="/docs/guides/express" />

  <Card title="Next.js" icon="layer-group" href="/docs/guides/nextjs" />

  <Card title="Fastify" icon="server" href="/docs/guides/fastify" />

  <Card title="NestJS" icon="server" href="/docs/guides/nestjs" />

  <Card title="Nuxt" icon="layer-group" href="/docs/guides/nuxt" />

  <Card title="SvelteKit" icon="layer-group" href="/docs/guides/sveltekit" />
</CardGroup>

Every other framework, runtime, and platform is in the sidebar. Building in Go instead? See the [Go SDK](/docs/guides/go-sdk).

## Source and Support

MIT licensed, developed at [github.com/AhaSend/ahasend-ts](https://github.com/AhaSend/ahasend-ts). Report reproducible bugs as GitHub issues, without credentials, message content, or webhook payloads.
