> ## 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 from a Vite App

> Send transactional email from a Vite app safely: an Express backend holds the AhaSend API key while the browser calls an authenticated endpoint.

This guide shows how to send transactional email from a Vite app through an Express backend with AhaSend, keep credentials on the server, and test a sandbox send.

[Vite](https://vite.dev) builds browser applications, so AhaSend credentials and
SDK calls belong in a backend. This guide adds an Express server and uses Vite's
development proxy. On a meta-framework, use its server integration instead,
for example [Astro](https://ahasend.com/docs/guides/astro.md).

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 Vite project
* Node.js 22 or newer for the backend, which the SDK requires
* Express 5 for the backend. Express 5 forwards a rejected promise from an `async` route handler to the error middleware below; on Express 4 the same rejection becomes an unhandled rejection and the request never completes. `npm install express` installs 5.
* An application authentication system that can identify the current user on the backend
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) permitted to send from that domain, and your account ID

## 1. Add a Backend

Install the SDK and Express in the package that runs your backend. A single
repository can keep them at its root; what matters is that no module imported by
the browser application imports `@ahasend/sdk`.

```bash npm theme={null}
npm install @ahasend/sdk express
```

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

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

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

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

Create an uncommitted backend environment file. Do not use a `VITE_` prefix:

```bash server/.env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
```

> [!WARNING]
>
> Vite exposes variables with its client prefix through `import.meta.env` and
> replaces them in the browser bundle. Never create
> `VITE_AHASEND_API_KEY`. If the project customizes `envPrefix` or `define`,
> ensure it does not expose or replace any `AHASEND_*` value. Rotate the key
> immediately if it is exposed.

Add `server/.env` to `.gitignore`. The backend below expects your authentication
integration to export `authorizeWelcome`. That middleware must validate the
user's server-side session, authorize this mutation, reject the request when the
CSRF token header sent in step 3 is missing or does not match the session, load
the user from trusted storage, and set `res.locals.user`. Do not populate the
user from request-body fields.

```js server/index.mjs theme={null}
import { createHash } from "node:crypto";
import express from "express";
import { AhaSendClient, isAhaSendError } from "@ahasend/sdk";
import { WebhookVerifier, expressWebhookHandler } from "@ahasend/sdk/webhooks";
import { authorizeWelcome } from "./auth.mjs";

const ahasend = AhaSendClient.fromEnv();
const app = express();

function isUser(value) {
  return (
    value &&
    typeof value === "object" &&
    typeof value.signupId === "string" &&
    value.signupId.length > 0 &&
    typeof value.email === "string" &&
    value.email.length > 0 &&
    (value.name === undefined || typeof value.name === "string")
  );
}

app.post("/api/welcome", authorizeWelcome, async (_req, res) => {
  const user = res.locals.user;
  if (!isUser(user)) {
    res.status(500).json({ error: "Unable to load user" });
    return;
  }

  const idempotencyKey = `sandbox-welcome-${createHash("sha256")
    .update(user.signupId)
    .digest("hex")}`;

  try {
    const result = await ahasend.messages.send(
      {
        from: { email: "hello@yourdomain.com", name: "Your App" },
        recipients: [{ email: user.email, name: user.name }],
        subject: "Welcome to Your App!",
        sandbox: true,
        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 },
    );

    const rejectedCount = result.data.filter(
      (entry) => entry.status === "error",
    ).length;
    if (rejectedCount > 0) {
      console.error("AhaSend rejected recipients", { rejectedCount });
      res.status(502).json({ error: "Email could not be queued" });
      return;
    }

    res.status(202).json({ queued: true });
  } catch (error) {
    if (!isAhaSendError(error)) throw error;
    const { code, status, requestId } = error.toJSON();
    console.error("AhaSend request failed", { code, status, requestId });
    res.status(502).json({ error: "Email could not be queued" });
  }
});

// The adapter reads the raw stream; do not put a global body parser before it.
app.post("/webhooks/ahasend", expressWebhookHandler(
  new WebhookVerifier(process.env.AHASEND_WEBHOOK_SECRET),
  async (_event, _req, res) => { res.status(204).end(); },
  { maxBodyBytes: 30_000_000 },
)); // Verification only; add durable deduplication before side effects.

// Register this after every route.
app.use((error, _req, res, next) => {
  if (res.headersSent) {
    next(error);
    return;
  }
  console.error("Request failed");
  res.status(500).json({ error: "Internal server error" });
});

app.listen(3000, "127.0.0.1");
```

Bind the backend to loopback. Vite's proxy connects over loopback, so nothing is
lost, and the backend port stays off the local network. The bind does not hide
the endpoint behind `vite --host`: Vite then accepts requests from the network
and proxies `/api` into the loopback backend, so the authentication middleware and the per-user rate
limit you add protect a live API key from becoming an open mail relay for
anyone who can reach the development server.

Rate-limit the mutation in addition to authenticating it: cap sends per session
and per user, and reject over the cap before calling `messages.send`.
Authentication alone bounds who can trigger a send, not how often.

The accepted send response is multi-status, so inspect every entry in
`result.data`; a resolved request can still contain an entry with
`status: "error"`. Anything that is not an AhaSend error is a local fault, so it
is rethrown to the error middleware rather than reported to the browser as an
upstream failure.

Reuse the same `signupId` only for retries of the same welcome-email payload.
The stable, hashed key lets AhaSend replay a stored outcome without putting the
raw identifier in request metadata. AhaSend matches a key against the exact
request body, so editing the subject or the HTML and then retrying for a user
who already received one is rejected with `AhaSendIdempotencyMismatchError`
(HTTP 422) rather than replayed; change the key prefix alongside the content. A
stored result is replayed for 24 hours and a server-error outcome releases the
key for re-execution, so the surrounding workflow must tolerate an uncertain
duplicate.

## 2. Proxy `/api` During Development

Vite's development server can forward `/api` requests to the backend, keeping
the browser on one origin:

```ts vite.config.ts theme={null}
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    proxy: {
      "/api": "http://localhost:3000",
    },
  },
});
```

Run the frontend and backend as separate development processes:

```bash theme={null}
npm run dev
node --env-file=server/.env server/index.mjs
```

The `server.proxy` setting covers Vite's own servers only — the development
server, and `vite preview`, whose `preview.proxy` defaults to it. For
production, build the frontend and serve `dist/` from static hosting; configure
the hosting platform or reverse proxy to send `/api` to the backend. Do not use
`vite preview` as a production server.

## 3. Call the Authenticated Endpoint

The browser sends no recipient or AhaSend credential. The backend derives the
recipient from the authenticated session:

```ts src/lib/sendWelcome.ts theme={null}
export async function sendWelcome(csrfToken: string): Promise<void> {
  const response = await fetch("/api/welcome", {
    method: "POST",
    credentials: "same-origin",
    headers: { "x-csrf-token": csrfToken },
  });
  if (!response.ok) throw new Error("Failed to send welcome email");
}
```

Use the header name and token your authentication integration issues. The custom
header is what makes the CSRF check in `authorizeWelcome` enforceable: a request
carrying one is no longer a simple cross-origin request, so another site cannot
forge it without CORS permission your backend never grants. A bodiless `POST`
with no custom header, by contrast, gives the server nothing to validate.

Call `sendWelcome()` from your React, Vue, or Svelte UI only as part of the
authorized application flow. Keep the endpoint on the same origin in production
as well as development, and configure the session cookie in your authentication
integration.

## Webhooks

Webhooks also belong on the backend. Mount the SDK's
`expressWebhookHandler` directly on its route, with no `express.json()`,
`express.raw()`, or other body parser in front of it. The adapter reads and
caps the exact raw stream before it verifies the signature and timestamp. The
[Express webhook guide](https://ahasend.com/docs/guides/express.md#handle-webhooks) contains the handler.

Add the route above the error middleware in `server/index.mjs`. Express only
routes an error to a handler registered after the failing route, so an adapter
setup error — a body parser that ran first, for instance — bypasses a handler
mounted earlier in the file. AhaSend delivers to the webhook path over HTTPS
from the public internet rather than through the browser's origin, and that path
is not under `/api`, so route it to the backend explicitly when you configure
production hosting.

Timestamp verification is not replay deduplication. Before returning a 2xx,
atomically persist the verified `webhook-id` with durable queue or outbox work.
Acknowledge an already claimed ID without processing it again, and make the
worker idempotent. Keep `event.data` out of your logs: it carries the recipient
address, sender, and subject, plus the opener's IP and user agent on open and
click events.

## 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] The SDK appears in the browser bundle
>
> A module in the browser import graph imports the SDK. Constructing the
> client there throws `AhaSendConfigurationError`: the SDK detects browser
> globals and refuses, so the key cannot reach a page even by accident. Move
> that module into the backend package or directory and communicate with it
> through the authenticated HTTP endpoint. Having the dependency in a shared
> `package.json` does not bundle it by itself.

> [accordion] fetch('/api/welcome') returns index.html or a 404
>
> During development, confirm the `server.proxy` entry is in
> `vite.config.ts`, restart Vite after changing the config, and confirm the
> backend listens on port 3000. If Vite and the backend run in separate
> containers or virtual machines, the loopback bind is why the proxy cannot
> connect: point `server.proxy` at the backend's reachable address and keep
> the port off the public network another way. In production, configure the
> hosting platform or reverse proxy separately; Vite's development proxy is
> not deployed.

> [accordion] import.meta.env.VITE\_AHASEND\_API\_KEY is visible
>
> Remove the variable and rotate the exposed key in the
> [dashboard](https://dash.ahasend.com/account/-/transactional/credentials). Keep the
> replacement only in the backend environment. Also inspect custom
> `envPrefix` and `define` settings, and never print the key while
> troubleshooting.

> [accordion] Webhook verification reports an already-parsed body
>
> A body parser ran before the adapter. Mount `expressWebhookHandler`
> directly before any global parser, or scope parsers to routes that need
> them. Do not add `express.raw()` in front of the adapter.

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