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

> Send transactional email from Nuxt server routes with the AhaSend TypeScript SDK, map runtimeConfig env vars, and verify webhooks in Nitro.

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

In [Nuxt](https://nuxt.com), declare the AhaSend credentials in the top-level, server-only section of `runtimeConfig`: anything under `runtimeConfig.public` is serialized into the client payload.

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

* A Nuxt project
* 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 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
```

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

## Configure Environment Variables

Declare the keys in `runtimeConfig` so Nuxt maps them from `NUXT_`-prefixed environment variables at runtime:

```ts nuxt.config.ts theme={null}
export default defineNuxtConfig({
  runtimeConfig: {
    // server-only: never put these under runtimeConfig.public
    ahasendApiKey: "",
    ahasendAccountId: "",
    ahasendWebhookSecret: "",
    welcomeRouteToken: "",
  },
});
```

```bash .env theme={null}
NUXT_AHASEND_API_KEY=aha-sk-...
NUXT_AHASEND_ACCOUNT_ID=your-account-uuid
NUXT_AHASEND_WEBHOOK_SECRET=aha-whsec-...
NUXT_WELCOME_ROUTE_TOKEN=replace-with-a-long-random-value
```

> [!WARNING]
>
> Anything under `runtimeConfig.public` (or a `NUXT_PUBLIC_`-prefixed variable) is serialized into the client payload and readable by anyone in the browser. Keep all AhaSend credentials in the top-level, server-only section of `runtimeConfig`.

## Create the Client

Create the client on first use and reuse it across requests:

```ts server/utils/ahasend.ts theme={null}
import { AhaSendClient } from "@ahasend/sdk";
import type { H3Event } from "h3";

let client: AhaSendClient | undefined;

function requireSecret(value: unknown, name: string): string {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error(`${name} is not configured`);
  }
  return value;
}

export function useAhaSend(event: H3Event): AhaSendClient {
  if (!client) {
    const config = useRuntimeConfig(event);
    client = new AhaSendClient({
      apiKey: requireSecret(config.ahasendApiKey, "NUXT_AHASEND_API_KEY"),
      accountId: requireSecret(config.ahasendAccountId, "NUXT_AHASEND_ACCOUNT_ID"),
    });
  }
  return client;
}
```

Files in `server/utils/` are auto-imported in all server routes, so `useAhaSend()` is available everywhere without an import. Passing the request event lets Nuxt apply the runtime environment overrides for that request.

## Send an Email from a Nuxt Server Route

```ts server/api/welcome.post.ts theme={null}
import { isAhaSendError } from "@ahasend/sdk";
import type { H3Event } from "h3";

type WelcomeRequest = {
  email: string;
  name: string;
  eventId: string;
};

function isWelcomeRequest(value: unknown): value is WelcomeRequest {
  if (typeof value !== "object" || value === null) return false;
  const body = value as Record<string, unknown>;
  return (
    typeof body.email === "string" &&
    body.email.length <= 320 &&
    /^[^\s@]+@[^\s@]+$/.test(body.email) &&
    typeof body.name === "string" &&
    body.name.length > 0 &&
    body.name.length <= 100 &&
    typeof body.eventId === "string" &&
    /^[A-Za-z0-9._:-]{1,200}$/.test(body.eventId)
  );
}

async function secretsEqual(left: string, right: string): Promise<boolean> {
  const encoder = new TextEncoder();
  const [leftHash, rightHash] = await Promise.all([
    crypto.subtle.digest("SHA-256", encoder.encode(left)),
    crypto.subtle.digest("SHA-256", encoder.encode(right)),
  ]);
  const leftBytes = new Uint8Array(leftHash);
  const rightBytes = new Uint8Array(rightHash);
  let difference = 0;
  for (let i = 0; i < leftBytes.length; i++) {
    difference |= leftBytes[i]! ^ rightBytes[i]!;
  }
  return difference === 0;
}

async function requireAuthorizedCaller(event: H3Event): Promise<void> {
  const expected = useRuntimeConfig(event).welcomeRouteToken;
  if (typeof expected !== "string" || expected.length === 0) {
    throw new Error("NUXT_WELCOME_ROUTE_TOKEN is not configured");
  }
  const authorization = getRequestHeader(event, "authorization") ?? "";
  const supplied = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
  if (!(await secretsEqual(supplied, expected))) {
    throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
  }
}

export default defineEventHandler(async (event) => {
  await requireAuthorizedCaller(event);

  const contentType = getRequestHeader(event, "content-type") ?? "";
  if (!contentType.toLowerCase().startsWith("application/json")) {
    throw createError({ statusCode: 415, statusMessage: "Expected application/json" });
  }

  const contentLength = Number(getRequestHeader(event, "content-length"));
  if (Number.isFinite(contentLength) && contentLength > 16_384) {
    throw createError({ statusCode: 413, statusMessage: "Request body too large" });
  }

  const { email, name, eventId } = await readValidatedBody<WelcomeRequest>(
    event,
    isWelcomeRequest,
  );

  let result;
  try {
    result = await useAhaSend(event).messages.send(
      {
        sandbox: true,
        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.",
      },
      { idempotencyKey: `sandbox-welcome:${eventId}` },
    );
  } catch (err) {
    if (isAhaSendError(err)) {
      const { code, status, requestId } = err.toJSON();
      console.error("AhaSend request failed", { code, status, requestId });
      throw createError({ statusCode: 502, statusMessage: "Failed to send email" });
    }
    throw err;
  }

  if (result.data.some((entry) => entry.status === "error")) {
    throw createError({ statusCode: 502, statusMessage: "Failed to queue email" });
  }

  setResponseStatus(event, 202);
  return { queued: true };
});
```

A 202 from AhaSend 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` while the call itself succeeds. Inspect every entry, but do not return recipient-level errors to the browser because they can contain addresses and provider diagnostics.

Require your application's authenticated server to call this route. If a browser calls it directly, replace the bearer-token check with your normal server-side session validation and derive the recipient from that verified identity. `eventId` must be a stable ID for the welcome-email business event, persisted by the caller and reused for every retry. The SDK reuses this explicit idempotency key during its retries; a 5xx can be re-executed, so your application must still reconcile an uncertain result instead of blindly creating a new event ID.

The `content-length` check rejects an oversized declared body, but Nitro does not cap request bodies on its own. Set a matching body-size limit at your reverse proxy or platform ingress in front of both routes.

Message sends draw on your account's [rate limit](https://ahasend.com/docs/api-reference/rate-limits.md) of 100 requests per second with a 200-request burst, shared by every API key on the account. The SDK already retries 429 and 5xx responses with backoff and honors `Retry-After`, so bound the concurrency of whatever calls this route rather than adding a second retry loop on top of it.

The send example uses `sandbox: true` to validate the request without delivering anything. Idempotency keys are scoped to the account and matched against a hash of the request body, so sandbox is not a separate namespace: reusing `welcome:<eventId>` with `sandbox` flipped is the same key carrying a different payload, which the API rejects with a 422 for the 24 hours the original record lives. Give sandbox sends their own key prefix.

## Handle Webhooks

Signature verification needs the **exact request bytes**. The Node-server example below reads `event.node.req` directly and counts bytes before buffering them. Do not run `readBody()` or other body-reading middleware first: parsing and re-serializing JSON changes the signed bytes. Answer every failed check with an empty body so nothing about the failure reaches the sender:

```ts server/api/webhooks/ahasend.post.ts theme={null}
import {
  AhaSendWebhookVerificationError,
  WebhookVerifier,
  isKnownWebhookEvent,
} from "@ahasend/sdk/webhooks";
import type { H3Event } from "h3";

const MAX_WEBHOOK_BYTES = 30_000_000;

function readLimitedWebhookBody(event: H3Event): Promise<Buffer | null> {
  return new Promise((resolve, reject) => {
    const request = event.node.req;
    const chunks: Buffer[] = [];
    let total = 0;
    const cleanup = () => {
      request.off("data", onData);
      request.off("end", onEnd);
      request.off("error", onError);
      request.off("aborted", onAborted);
    };
    const onError = (error: Error) => { cleanup(); reject(error); };
    const onAborted = () => onError(new Error("Request aborted"));
    const onEnd = () => { cleanup(); resolve(Buffer.concat(chunks, total)); };
    const onData = (chunk: Buffer) => {
      total += chunk.length;
      if (total > MAX_WEBHOOK_BYTES) {
        cleanup();
        request.pause();
        // Send 413 before closing the unread connection.
        event.node.res.setHeader("Connection", "close");
        event.node.res.once("finish", () => request.destroy());
        resolve(null);
        return;
      }
      chunks.push(chunk);
    };
    request.on("data", onData);
    request.once("end", onEnd);
    request.once("error", onError);
    request.once("aborted", onAborted);
  });
}

let verifier: WebhookVerifier | undefined;

function useWebhookVerifier(event: H3Event): WebhookVerifier {
  if (!verifier) {
    const secret = useRuntimeConfig(event).ahasendWebhookSecret;
    if (typeof secret !== "string" || secret.length === 0) {
      throw new Error("NUXT_AHASEND_WEBHOOK_SECRET is not configured");
    }
    verifier = new WebhookVerifier(secret);
  }
  return verifier;
}

export default defineEventHandler(async (event) => {
  // Built before the verification try/catch so a missing secret surfaces as a
  // server error instead of being answered as a signature failure.
  const webhookVerifier = useWebhookVerifier(event);

  const declaredLength = Number(getRequestHeader(event, "content-length"));
  if (Number.isSafeInteger(declaredLength) && declaredLength > MAX_WEBHOOK_BYTES) {
    setResponseStatus(event, 413);
    return null;
  }

  const rawBody = await readLimitedWebhookBody(event);
  if (rawBody === null || rawBody.byteLength === 0) {
    setResponseStatus(event, rawBody === null ? 413 : 400);
    return null;
  }

  let webhookEvent;
  try {
    webhookEvent = await webhookVerifier.parse(getRequestHeaders(event), rawBody);
  } catch (err) {
    const tooLarge =
      err instanceof AhaSendWebhookVerificationError && err.reason === "body_too_large";
    setResponseStatus(event, tooLarge ? 413 : 400);
    return null;
  }

  if (isKnownWebhookEvent(webhookEvent)) {
    // Enqueue trusted work here; do not log the event or recipient data.
    console.log("Verified AhaSend webhook", { type: webhookEvent.type });
  }

  setResponseStatus(event, 204);
  return null;
});
```

Do not convert the event into a web `Request` (with `toWebRequest` or `fromWebHandler`) so you can reuse a fetch-style webhook adapter: Nitro's Node request stream is wrapped in a stream that throws an uncaught error if the consumer stops reading before the upload finishes, which an oversized delivery does.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-app.com/api/webhooks/ahasend`, and copy its secret into `NUXT_AHASEND_WEBHOOK_SECRET` exactly as shown (including the `aha-whsec-` prefix).

Signature timestamp checks do not prevent a valid delivery from being replayed inside the accepted window. Before adding side effects, atomically store the `webhook-id` header in a table with a unique constraint together with a durable work/outbox record. On a uniqueness conflict, acknowledge the delivery without enqueuing the work again. Process that work idempotently, acknowledge unknown event types, and return a successful response quickly so AhaSend does not retry completed work.

## 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] Webhook verification always returns 400
>
> Something consumed or rewrote the request body before the bounded stream reader could read the exact bytes the HMAC was computed over, or the configured webhook secret does not match this dashboard endpoint. Keep body-parsing server middleware off this route, and check that no proxy in front of Nitro re-encodes the payload.

> [accordion] 401 AhaSendAuthenticationError
>
> The API key never reached the AhaSend SDK client on the server. Check that `nuxt.config.ts` declares `ahasendApiKey` in `runtimeConfig` and that the env var is named exactly `NUXT_AHASEND_API_KEY`, since Nuxt only maps variables whose names match the config key. Restart the dev server after editing `.env`.

> [accordion] Credentials work locally but are missing after deploy
>
> A built Nuxt server does not read your local `.env` file. Configure the matching `NUXT_AHASEND_*` and `NUXT_WELCOME_ROUTE_TOKEN` values in your deployment platform's runtime environment, never as `NUXT_PUBLIC_` variables.

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