> ## 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 in the EU, or send-us.ahasend.com as a US connection point forwarding to the EU. 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 v0 App

> Send transactional email from a v0-generated Next.js app with AhaSend: a server-side Route Handler, Vercel environment variables, and sandbox tests.

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

[v0](https://v0.app) can generate full-stack Next.js applications, including Route Handlers and other server-side code. When you ask it to add email, keep the AhaSend API key and SDK calls behind that server boundary.

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

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

## AhaSend Rules for AI Coding Agents

Add this shared block to your project instructions:

```markdown theme={null}
## AhaSend rules for AI coding agents

- Use @ahasend/sdk on the server (Node.js 22+, Deno, Bun, or a supported edge runtime).
- Create a send-only API Key v2 under Credentials → Add, scoped to messages:send:{your-domain}.
  Use messages:send:all only for sending from many domains. Management calls need a separate
  full key from Account Settings → API Keys with the needed scopes.
- Never put API keys in browser code, logs, prompts, or source control. Use server secrets.
- Use AhaSendClient with apiKey and accountId, then await client.messages.send(request, options).
  The v2 path is POST /v2/accounts/{account_id}/messages. from is an object, recipients is an array,
  and content fields are text_content and html_content. Use Create Conversation for CC/BCC.
- Authenticate send routes and take recipients and business-event IDs from trusted server data.
  Add request-size and rate limits. Await sending or commit a durable job before returning success.
- Require AHASEND_DELIVERY_MODE to be sandbox or live; reject missing or other values.
  Use a sandbox-mode credential for agent tests. Never decide delivery mode from NODE_ENV.
- Set sandbox from that explicit mode. Prefix stable business-event idempotency keys with the mode.
  Reuse a key only with the same request. Ordinary results last 24 hours; 5xx may re-execute.
- Inspect every result.data item: queued and scheduled were accepted; error was rejected and has
  a null id. Return a non-2xx result or fail the job when any recipient is rejected.
- Verify webhooks with @ahasend/sdk/webhooks before parsing or acting on untrusted data.
  Await verification. Use the exact raw body and the literal UTF-8 bytes of the full secret,
  prefix included; never base64-decode it. Bound the body to 30,000,000 bytes before reading it.
- Return 2xx for verified unknown events. Commit webhook-id and durable work atomically,
  acknowledge committed duplicates, and retry storage failures. The default clock tolerance
  is five minutes. Never log payloads, recipient addresses, signatures, secrets, or idempotency keys.
- Use a verified sender domain. Schedule the first attempt in the future and within 7 days.
  Encode binary attachment bytes as base64 yourself before setting base64: true.
- Check the API and SDK docs instead of inventing packages, fields, event names, or flags.
  Docs index: https://ahasend.com/docs/llms.txt
```

## Prerequisites

* A v0 project connected to a Vercel deployment
* An [AhaSend account](https://dash.ahasend.com/user/register) with a [verified sending domain](/docs/domains)
* An [API key](/docs/send-api/credentials) with a send scope, plus your account ID

Verify the domain and create the key in the [dashboard](https://dash.ahasend.com) first: the [quickstart](/docs/quickstart) walks through both. Scope the key to `messages:send:{your-domain}` rather than `messages:send:all`, so a leak exposes one domain's outbound mail instead of your whole account.

## Add Server-Side Vercel Variables

<Warning>
  If v0 already put your key in a `NEXT_PUBLIC_` variable or referenced it from a client component, treat it as leaked. Delete the variable, rotate the key in the dashboard, and only then continue.
</Warning>

A v0 project inherits its environment variables from the Vercel project it publishes to. Open the project menu in v0, then **Settings → Environment Variables**, or edit the same values from the Vercel project's **Settings → Environment Variables**. Add:

```bash theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_DELIVERY_MODE=sandbox
```

Do not use a `NEXT_PUBLIC_` prefix. Add all three to every environment the application runs in, and mark `AHASEND_API_KEY` as **Sensitive** in Preview and Production — Vercel offers that option only for those two environments.

Give the **Development** environment its own copy of all three variables, holding a separate key. The v0 preview window reads only Development values, and it cannot read Sensitive variables at all, so the Preview and Production key is unavailable there by design. Use a [sandbox credential](/docs/send-api/sandbox) for Development: that copy is readable rather than sensitive, and it cannot deliver real mail if it leaks. The client below throws on a missing variable while the server module initializes, so a Development gap shows up as a failing preview instead of a silent misconfiguration.

Vercel makes environment variables available during builds and function execution, and changes apply only to new deployments — publish again after editing them.

Next.js only includes variables prefixed with `NEXT_PUBLIC_` in the browser bundle. Project members with sufficient Vercel access can still read ordinary Vercel environment variables, which is why the Preview and Production key uses the Sensitive option.

## Install the SDK

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

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

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

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

## Try a Sandbox Send

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

Create the client once at module scope. This keeps retry, telemetry, and idempotency configuration consistent across every route, and avoids rebuilding the client on each request.

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

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

export const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});

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

export const sandboxMode = deliveryMode === "sandbox";
```

## Ask v0 for a Route Handler

Be explicit about the trust boundary. A public route that accepts any recipient address becomes an email relay, even though the API key itself stays hidden. Use a prompt like this:

> "Add a Next.js Route Handler at `app/api/send-confirmation/route.ts` that uses `@ahasend/sdk`. Import the shared client and sandbox setting from `lib/ahasend.ts`. Require the app's existing authenticated session and authorization checks. Load the pending confirmation record, recipient address, confirmation URL, and stable record ID from server-side storage; never accept these values or the idempotency key from the browser. Use a mode-prefixed stable idempotency key so sandbox and live sends cannot collide. Send from `hello@yourdomain.com`. Await the send and respond only after it resolves; do not move it into background work that runs after the response, because the function can be frozen once it returns. Return only accepted and rejected counts, never recipient data or provider error text. The client component should POST an empty request to the route, handle non-success responses, and disable duplicate submission. Preserve the app's normal CSRF and abuse-prevention controls."

Adapt the two application-specific imports below to your authentication and data layer. The key property is that the browser cannot choose the recipient or idempotency key:

```ts app/api/send-confirmation/route.ts theme={null}
import { NextResponse } from "next/server";
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend, sandboxMode } from "@/lib/ahasend";
import { requireUser } from "@/lib/auth";
import { getPendingEmailConfirmation } from "@/lib/email-confirmations";

export async function POST(request: Request) {
  try {
    const user = await requireUser(request);
    if (!user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const confirmation = await getPendingEmailConfirmation(user.id);
    if (!confirmation) {
      return NextResponse.json({ error: "No pending confirmation" }, { status: 404 });
    }

    const result = await ahasend.messages.send(
      {
        from: { email: "hello@yourdomain.com", name: "Your App" },
        recipients: [{ email: confirmation.email }],
        subject: "Confirm your email",
        text_content: `Thanks for signing up. Confirm your email: ${confirmation.url}`,
        sandbox: sandboxMode,
      },
      {
        idempotencyKey: `${sandboxMode ? "sandbox" : "live"}-email-confirmation-${confirmation.id}`,
      },
    );

    const rejected = result.data.filter((message) => message.status === "error").length;
    const accepted = result.data.length - rejected;

    if (accepted === 0 || rejected > 0) {
      return NextResponse.json({ accepted, rejected }, { status: 502 });
    }

    return NextResponse.json({ accepted, rejected }, { status: 202 });
  } catch (error) {
    if (error instanceof AhaSendAPIError) {
      console.error("AhaSend request failed", {
        status: error.status,
        code: error.code,
        requestId: error.requestId,
      });
      return NextResponse.json({ error: "Failed to send email" }, { status: 502 });
    }
    throw error;
  }
}
```

AhaSend send responses are multi-status: the promise can resolve while an individual recipient has `status: "error"`. Inspect every entry. Do not return or log a rejected entry because it contains the recipient and free-form provider error text.

The SDK retries only operations its generated policy marks safe, idempotent, or protected by a key. For an eligible operation it handles 408, 429, 5xx, network failures, and timeouts. An automatically generated send key covers one logical SDK call and its internal attempts. A new route request is a new logical call, so the example supplies a stable key derived from the server-side confirmation record. The mode prefix prevents a recent sandbox outcome from colliding with a live send. Reuse a key only for the exact same operation and payload.

## Call the Route from the Form

The browser sends neither the recipient nor any AhaSend credential:

```tsx components/signup-form.tsx theme={null}
async function sendConfirmation() {
  const response = await fetch("/api/send-confirmation", { method: "POST" });

  if (!response.ok) {
    throw new Error("The confirmation email could not be queued");
  }

  return response.json();
}
```

Disable the submit control while this request is pending. Keep the application's session, authorization, CSRF, and abuse-prevention controls on the route; idempotency prevents duplicate processing but is not authentication or rate limiting.

## Test in Sandbox Mode

Keep `AHASEND_DELIVERY_MODE=sandbox` while you build. Sandbox mode accepts a normal request without delivering email, returns a normal-shaped response, and can trigger the applicable configured webhook events.

Use `sandbox_result: "bounce"` to simulate a hard bounce, or `"defer"`, `"fail"`, and `"suppress"` for other outcomes, and confirm your event handling. The full set is in the [sandbox mode guide](/docs/send-api/sandbox). When ready, deliberately change `AHASEND_DELIVERY_MODE` to `live` in the **Production** environment only, publish again, and send one test to an address you control. Leave Development on `sandbox`: a dedicated sandbox credential simulates every send regardless of this setting, so a live value there is misleading rather than useful. Do not derive this switch from `NODE_ENV`, because preview deployments commonly use production optimizations too.

## Audit Before You Ship

<Steps>
  <Step title="Search the repo for credentials">
    No `aha-sk-` string and no `NEXT_PUBLIC_AHASEND...` variable should appear in client code. If v0 exposed a key, remove it and rotate it.
  </Step>

  <Step title="Review the route's authority">
    The form sends no recipient or confirmation link. The route authenticates and authorizes the user, then loads the recipient, confirmation URL, and stable record ID from server-side storage.
  </Step>

  <Step title="Review responses and logs">
    Return and log only aggregate counts, HTTP status, SDK error code, and request ID. Do not expose recipients, message content, whole SDK objects, error messages, or idempotency keys.
  </Step>

  <Step title="Redeploy after configuration changes">
    Required variables fail fast during server module initialization. Fix the deployment configuration rather than catching and hiding that error.
  </Step>
</Steps>

## Going Further

For substitutions, attachments and scheduled sends, use the [sending guide](/docs/send-api/send-email). The [Node.js SDK](/docs/guides/nodejs-sdk) and [Go SDK](/docs/guides/go-sdk) references explain client options, per-recipient results and retries. Use [idempotency rules](/docs/api-reference/idempotency) when your application retries a business operation.

## Troubleshooting

<AccordionGroup>
  <Accordion title="AhaSend import fails only in the browser">
    The SDK is being imported by a client component. Move the import and send into the Route Handler, and make the component call that route with `fetch`.
  </Accordion>

  <Accordion title="An environment variable is undefined">
    In the v0 preview window this is almost always a missing **Development** value: previews read only that environment, and never read Sensitive variables. In a deployment, the variable may be missing from the environment that deployment targets, or may have been added after the last publish. Correct the environment assignment, then publish again.
  </Accordion>

  <Accordion title="API key visible in the browser bundle">
    Remove the `NEXT_PUBLIC_` prefix, rotate the leaked key in the dashboard, and read the replacement only from server code.
  </Accordion>

  <Accordion title="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>
</AccordionGroup>

## Related Guides

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


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