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

> Send transactional email from a Bun.serve route with the AhaSend TypeScript SDK, with no shims or dotenv, and verify signed AhaSend webhooks in Bun.

This guide shows how to send transactional email from Bun with AhaSend, keep credentials on the server, and test a sandbox send.

[Bun](https://bun.sh) is one of the runtimes the SDK officially supports, alongside Node.js and Deno. The package runs unchanged on the latest Bun, webhook signature verification included.

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

* Bun installed
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) with the `messages:send:{your-domain}` scope (`messages:send:all` for sending from many domains), and your account ID

## Install the SDK

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

## 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 Bun. 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 `bun run 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.

The latest Bun is a supported runtime for the package, so no shims or flags are needed.

## Configure Environment Variables

Create `.env` yourself and ensure it is ignored by git before adding values:

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
WELCOME_ENDPOINT_TOKEN=replace-with-a-long-random-value
```

> [!NOTE]
>
> Bun loads `.env` automatically, no `dotenv` package or `--env-file` flag needed. The variables are available on `process.env` (and `Bun.env`) as soon as your script starts.

## 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 const ahasend = new AhaSendClient({
  apiKey: process.env.AHASEND_API_KEY!,
  accountId: process.env.AHASEND_ACCOUNT_ID!,
});
```

## Send an Email from a Bun.serve Route

`Bun.serve` with a fetch handler is all the HTTP server you need. A send route reaches into your AhaSend quota and puts caller-supplied text into mail you sign, so it authenticates the caller and validates the body before it calls the SDK:

```ts server.ts theme={null}
import { createHash, timingSafeEqual } from "node:crypto";
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend } from "./lib/ahasend";

import {
  AhaSendWebhookVerificationError,
  WebhookVerifier,
  isKnownWebhookEvent,
} from "@ahasend/sdk/webhooks";

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


const welcomeEndpointToken = process.env.WELCOME_ENDPOINT_TOKEN;
if (!welcomeEndpointToken) throw new Error("WELCOME_ENDPOINT_TOKEN is required");
const expectedTokenHash = createHash("sha256").update(welcomeEndpointToken).digest();

function isAuthorized(header: string | null): boolean {
  const provided = header?.startsWith("Bearer ") ? header.slice(7) : "";
  if (!provided) return false;
  return timingSafeEqual(createHash("sha256").update(provided).digest(), expectedTokenHash);
}

Bun.serve({
  port: 3000,
  maxRequestBodySize: 30_000_000,
  development: false,
  error(err) {
    console.error("Unhandled server error", { name: err.name });
    return new Response("Internal Server Error", { status: 500 });
  },
  async fetch(req) {
    const url = new URL(req.url);

    if (req.method === "POST" && url.pathname === "/api/welcome") {
      if (!isAuthorized(req.headers.get("authorization"))) {
        return Response.json({ error: "Unauthorized" }, { status: 401 });
      }

      const body = (await req.json().catch(() => null)) as {
        email?: unknown;
        name?: unknown;
        eventId?: unknown;
      } | null;
      const { email, name, eventId } = body ?? {};
      if (typeof eventId !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(eventId)) {
        return Response.json({ error: "A valid eventId is required" }, { status: 400 });
      }
      if (typeof email !== "string" || !email.includes("@")) {
        return Response.json({ error: "A valid `email` is required" }, { status: 400 });
      }
      if (name !== undefined && typeof name !== "string") {
        return Response.json({ error: "`name` must be a string" }, { status: 400 });
      }

      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: true,
        }, { idempotencyKey: `sandbox-welcome-${eventId}` });

        const rejected = result.data.filter((r) => r.status === "error");
        if (rejected.length > 0) console.warn(`${rejected.length} recipient(s) rejected`);

        return Response.json({ statuses: result.data.map((r) => r.status) }, { status: rejected.length ? 422 : 202 }); // ["queued"]
      } catch (err) {
        if (err instanceof AhaSendAPIError) {
          console.error("AhaSend request failed", {
            status: err.status,
            requestId: err.requestId,
          });
          return Response.json({ error: "Failed to send email" }, { status: 502 });
        }
        throw err;
      }
    }

    if (req.method === "POST" && url.pathname === "/webhooks/ahasend") {
      let event;
      try {
        const rawBody = new Uint8Array(await req.arrayBuffer());
        event = await verifier.parse(req.headers, rawBody);
      } catch (err) {
        if (err instanceof AhaSendWebhookVerificationError) {
          return new Response("Invalid signature", { status: 400 });
        }
        throw err;
      }

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

      return new Response(null, { status: 200 });
    }

    return new Response("Not found", { status: 404 });
  },
});
```

Run it with `bun run server.ts`. Keep `development: false` and the `error` handler: with `NODE_ENV` unset, `Bun.serve` defaults to development mode, and its built-in 500 page hands the thrown error's message and the surrounding source back to whoever made the request — including on the webhook route, which anyone who finds the URL can reach.

Call this route only from trusted server-side code with `Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>`, and replace the token check with your application's normal authentication and authorization if the endpoint is user-facing. The check compares SHA-256 digests through `timingSafeEqual` rather than `!==`, because JavaScript's string comparison returns as soon as two characters differ and leaks the token prefix to an attacker who can time repeated requests; hashing first also keeps the comparison from revealing the token's length. Serve both routes only over HTTPS, terminating TLS at Bun or a trusted reverse proxy, and add rate limiting plus a body limit sized for JSON in front of the send route — `maxRequestBodySize` is a server-wide setting, so on its own it lets a 30 MB body reach either path. Never expose a recipient-controlled send endpoint without access control.

A 202 is **multi-status**: `result.data` carries one entry per recipient, and an individual recipient can come back `status: "error"` with a null `id` (a suppressed address, say) while the call itself succeeds, so check every entry, not just the first.

The SDK retries transient failures automatically, but a retry after a 5xx can still send twice. For a business operation your application may retry later, pass a stable `idempotencyKey`, reuse it only with the exact same request payload, and remember that the server retains non-secret results for 24 hours.

The send request already uses `sandbox: true` to validate it without delivering anything. Sandbox is a body field, so a key already used for a sandbox send is rejected when the same key is replayed for the live send — give the two runs different keys.

## Handle Webhooks

There's no Bun-specific adapter, and you don't need one: `verifier.parse()` accepts a Fetch `Headers` object and raw `Uint8Array` body directly. `parse()` is asynchronous, so await it. Read the body with `req.arrayBuffer()`, not `req.json()`, so the verifier sees the exact bytes AhaSend signed. The server configuration above caps request bodies at the verifier's fixed 30,000,000-byte limit before the handler reads them.

The complete `server.ts` above mounts `/webhooks/ahasend` before the fallback response.

Narrow the catch to `AhaSendWebhookVerificationError` and rethrow anything else. Every non-2xx answer counts as a failed delivery: [6 attempts over about 16 minutes, with a webhook disabled after more than 100 consecutive failed attempts](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md). A bug of your own should therefore surface as a 5xx rather than as a rejection that looks like a bad signature, which quietly spends that budget.

Webhooks can be delivered more than once. After verification and before performing side effects, atomically commit the `webhook-id` and durable work (such as an outbox job) in the same transaction. Acknowledge an ID that transaction has already committed without enqueueing it again.

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] 401 AhaSendAuthenticationError
>
> The API key is missing, malformed, or revoked. Bun loads `.env` automatically; if the expected file is not being found, confirm the process working directory or select it explicitly with `bun --env-file=/path/to/.env run server.ts`. Check only whether the variable is present—for example, `console.log(Boolean(process.env.AHASEND_API_KEY))`—and never log any part of the key.

> [accordion] Webhook verification fails with 400
>
> Make sure you pass `new Uint8Array(await req.arrayBuffer())` to `verifier.parse()`, not a re-serialized `JSON.stringify(await req.json())`: re-serialization changes key order and whitespace, so the signature no longer matches.

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

> [accordion] SDK works locally but you plan to deploy to an edge platform
>
> Edge platforms are supported, including Cloudflare workerd with no `nodejs_compat` flag. See the [Cloudflare Workers guide](https://ahasend.com/docs/guides/cloudflare-workers.md) for the details there.

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