> ## 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 Bolt.new Project

> Add transactional email to a Bolt.new app with the AhaSend SDK from a Bolt or Supabase server function or Node backend, with the API key kept server-side.

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

[Bolt.new](https://bolt.new) develops your app in a browser-based WebContainer. A WebContainer runs inside the browser tab, so it is not a safe place for an AhaSend API key: code there can make network requests, but it cannot keep the key from someone who can inspect or edit the project. Put the send in a Bolt or Supabase server function, or in a separately deployed Node backend.

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
```

This example pins `@supabase/server` to 1.4.1. Its [withSupabase helper](https://supabase.github.io/server/functions/index.withSupabase.html) passes the authenticated Supabase client and user claims to the handler.

## Prerequisites

* A Bolt project with Bolt Database or Supabase connected, or a separately deployed Node backend
* 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

Set the AhaSend side up in the [dashboard](https://dash.ahasend.com) before Bolt writes a line: the [quickstart](/docs/quickstart) covers domain verification and key creation. Scope the key to `messages:send:{your-domain}` instead of `messages:send:all` so a leaked key can only touch one domain's mail.

## Pick Your Backend

Two rules apply whichever shape your project takes. Never call AhaSend from the frontend or the WebContainer preview: that makes your API key available in the browser. Put the key in the secret store for the backend that sends the message, not in a project environment variable used by preview or client code.

<Warning>
  If Bolt already put the key in a `VITE_`-prefixed variable or referenced it from a client component, that value is public. Remove it and rotate the key in the dashboard before you continue.
</Warning>

[`@ahasend/sdk`](https://www.npmjs.com/package/@ahasend/sdk) supports the common Bolt backend choices. What differs is where the key lives:

| Your backend | Where the key goes |
| - | - |
| Bolt Database server function | Bolt Database **Secrets**, read with `Deno.env.get()` |
| Supabase Edge Function (Deno) | Supabase secrets, read with `Deno.env.get()` |
| Node server (Express, Fastify, Nitro, deployed to a Node host) | That host's environment variables, read from `process.env` |

Bolt and Supabase server functions use the edge-function path below.

## Send with the SDK

Install the SDK, which handles retries, idempotency, and typed errors:

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

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

  ```bash bun theme={null}
  bun add @ahasend/sdk
  ```
</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 Deno 2.x. Save this as `quick-send.mjs`:

```js quick-send.mjs theme={null}
import { AhaSendClient } from "npm:@ahasend/sdk";
try {
  const client = new AhaSendClient({ apiKey: Deno.env.get("AHASEND_API_KEY"), accountId: Deno.env.get("AHASEND_ACCOUNT_ID") });
  const result = await client.messages.send({
    from: { email: Deno.env.get("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.");
  Deno.exit(1);
}
```

Run `deno run --env-file=.env --allow-net=api.ahasend.com --allow-env=AHASEND_API_KEY,AHASEND_ACCOUNT_ID,AHASEND_FROM quick-send.mjs`. Deno resolves the `npm:` import. This is a local SDK check; the server function integration follows below. The output lists recipient statuses and the process fails on a rejected recipient or failed request.

In a Bolt or Supabase server function there is nothing to install in the app package: import it with `import { AhaSendClient } from "npm:@ahasend/sdk"`.

If you use Bolt Database, open the database icon, select **Secrets**, and create `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_SANDBOX` (set it to `true` while testing). Bolt makes database secrets available only to server functions.

If you use your own Supabase project, add the same credentials as Supabase secrets:

```bash theme={null}
supabase secrets set AHASEND_API_KEY=aha-sk-your-key
supabase secrets set AHASEND_ACCOUNT_ID=your-account-uuid
supabase secrets set AHASEND_SANDBOX=true
```

The Supabase dashboard does the same under your project's Edge Functions settings. On a Node host, set the same three variables in that host's environment settings.

A server function is a public HTTPS endpoint: anyone who learns its URL can call it. Authentication and server-side authorization are what keep it from becoming an open mail relay, so name them in the prompt along with the architecture, or Bolt reaches for a client-side hook:

> "Add a server function called `send-receipt` that emails an order confirmation with `@ahasend/sdk`. Authenticate the caller with `withSupabase({ auth: 'user' })` and keep JWT verification enabled. Accept only an order ID, then load the recipient address, name, and order details through the caller's RLS-scoped Supabase client instead of trusting them from the request body. Read the credentials from server-function secrets with `Deno.env.get()`, fail at startup if `AHASEND_API_KEY` or `AHASEND_ACCOUNT_ID` is missing, stay in sandbox mode unless `AHASEND_SANDBOX` is exactly `false`, and create one `AhaSendClient` at module scope. Use a stable, sandbox-or-production-specific idempotency key for each order. Treat the resolved send as an accepted HTTP 202 response, but inspect every recipient result for `status: \"error\"`. Return a generic error to the browser, and never log recipients, message content, secrets, or whole error objects. The frontend must call this function and must never call AhaSend directly or contain the API key."

The function it generates should look close to this. Create the client once at module scope, authenticate the caller, and load the order before sending:

```ts supabase/functions/send-receipt/index.ts theme={null}
import { AhaSendAPIError, AhaSendClient, isAhaSendError } from "npm:@ahasend/sdk";
import { withSupabase } from "npm:@supabase/server@1.4.1";

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

const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});
const sandbox = Deno.env.get("AHASEND_SANDBOX") !== "false";
const environment = sandbox ? "sandbox" : "production";

function escapeHtml(value: string) {
  return value.replace(/[&<>"']/g, (character) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#39;",
  })[character]!);
}

export default {
  fetch: withSupabase({ auth: "user" }, async (req, ctx) => {
    if (req.method !== "POST") {
      return Response.json(
        { error: "Method not allowed" },
        { status: 405, headers: { Allow: "POST" } },
      );
    }
    if (Number(req.headers.get("content-length")) > 1024) {
      return Response.json({ error: "Request body too large" }, { status: 413 });
    }

    let orderId: unknown;
    try {
      ({ orderId } = (await req.json()) as { orderId?: unknown });
    } catch {
      return Response.json({ error: "Invalid JSON body" }, { status: 400 });
    }
    if (typeof orderId !== "string" || orderId.length === 0) {
      return Response.json({ error: "orderId is required" }, { status: 400 });
    }

    // ctx.supabase runs under the caller's RLS policies, so an order that
    // belongs to someone else comes back empty instead of being emailed.
    const { data: order } = await ctx.supabase
      .from("orders")
      .select("id, customer_email, customer_name")
      .eq("id", orderId)
      .maybeSingle<{ id: string; customer_email: string; customer_name: string }>();
    if (!order) {
      return Response.json({ error: "Order not found" }, { status: 404 });
    }

    try {
      const result = await ahasend.messages.send(
        {
          from: { email: "orders@yourdomain.com", name: "Your Store" },
          recipients: [{ email: order.customer_email, name: order.customer_name }],
          subject: `Order ${order.id} confirmed`,
          html_content: `<p>Thanks ${escapeHtml(order.customer_name)}, order ${escapeHtml(order.id)} is confirmed.</p>`,
          text_content: `Thanks ${order.customer_name}, order ${order.id} is confirmed.`,
          sandbox,
        },
        { idempotencyKey: `order-receipt-${environment}-${order.id}` },
      );

      const rejectedCount = result.data.filter((recipient) => recipient.status === "error").length;
      if (rejectedCount > 0) {
        console.warn("AhaSend rejected recipients", { rejectedCount });
        return Response.json({ error: "Email was not accepted" }, { status: 502 });
      }

      return Response.json({ queued: true }, { status: 202 });
    } catch (err) {
      if (isAhaSendError(err)) {
        console.error(
          "AhaSend request failed",
          err instanceof AhaSendAPIError
            ? { errorCode: err.code, status: err.status, requestId: err.requestId }
            : { errorCode: err.code },
        );
        return Response.json({ error: "Failed to send email" }, { status: 502 });
      }
      throw err;
    }
  }),
};
```

Note what never crosses the boundary: the recipient address comes from the order row, not the request, and the error path logs a status and request ID rather than the caught error, whose `message` and `body` carry the provider's response. The browser gets a generic 502.

On a Node backend the code differs only in how it reads configuration and how it is routed: swap `Deno.env.get(...)` for `process.env`, and call the send from your Express, Fastify, or Nitro route after that route has authenticated the caller and loaded the order itself. Full framework setup lives in the [Express](/docs/guides/express) and [Next.js](/docs/guides/nextjs) guides.

## Handle the 202 Response

Success is **202**, not 200: AhaSend has accepted and queued your message for asynchronous delivery. A handler that expects 200 reports failures that did not happen.

The 202 body is multi-status: it carries one result per recipient. An individual recipient can come back `status: "error"` with a null `id`, a suppressed address for example, while the request succeeded and the promise resolves. Inspect every entry, not just the first.

## Test in Sandbox Mode

Test through the deployed server function, including when you trigger it from Bolt's preview; do not put the key in the WebContainer to test locally. Keep `AHASEND_SANDBOX=true` and AhaSend accepts the message, validates it, triggers the relevant webhooks, and logs it in your dashboard, then stops before delivery. That gives you a real round-trip to the API confirmed by a real 202, with nothing landing in an inbox while you iterate.

Rehearse the failures too. Add `sandbox_result: "bounce"` to simulate a hard bounce, or `"defer"`, `"fail"`, and `"suppress"` for the rest, and check your code handles each. The full set is in the [sandbox mode guide](/docs/send-api/sandbox).

## Deploy and Verify the Key

Your deployed backend and frontend live in different homes. Before you call it done:

<Steps>
  <Step title="Search for the key in frontend code and preview settings">
    No `aha-sk-` string anywhere in the frontend and no AhaSend variable exposed to the WebContainer. The key belongs in Bolt Database Secrets, Supabase Edge Function secrets, or the environment of the Node host that sends the message.
  </Step>

  <Step title="Confirm the browser calls your backend">
    The frontend calls your Edge Function or your Node route; only that backend talks to `api.ahasend.com`.
  </Step>

  <Step title="Confirm the function rejects unauthenticated callers">
    Call the deployed function URL with no session attached. It must answer 401 rather than send anything. Keep JWT verification enabled on it, and check that the recipient address is read from your database rather than from the request body: a function that emails whatever address the caller passes is a relay even when callers have to sign in.
  </Step>

  <Step title="Flip sandbox off deliberately">
    Set `AHASEND_SANDBOX=false` in the backend's secret store, then send one real test to an address you control. Any other value, including a missing value, keeps the example in sandbox mode.
  </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="Error importing @ahasend/sdk in a Supabase Edge Function">
    Confirm the import is server-side and uses the Deno npm specifier exactly as shown: `npm:@ahasend/sdk`. There is nothing to install in the app's `package.json` for this path — the runtime resolves the specifier. If it still fails, redeploy and inspect the function's logs.
  </Accordion>

  <Accordion title="undefined API key at runtime">
    The key was set in the project preview or frontend host instead of where the send runs. For Bolt Database, use **Database > Secrets**. For your own Supabase project, use `supabase secrets set`; hosted Edge Functions receive updated secrets without a redeploy.
  </Accordion>

  <Accordion title="The frontend gets 401 from the server function">
    The caller has no session. Sign in and invoke the function through the authenticated Supabase client so the request carries the user's JWT. Do not disable JWT verification or switch the function to unauthenticated access to make the error go away: that publishes a send endpoint to the internet.
  </Accordion>

  <Accordion title="The server function works while testing but not after publish">
    Confirm the published frontend calls the same server-function URL and that the function's own secret store contains the API key, account ID, and intended sandbox setting. Then look at the AhaSend dashboard logs: a message that never reached AhaSend leaves no log line.
  </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.