> ## 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 Lovable App

> Send transactional email from a Lovable app with AhaSend: a Supabase Edge Function, API key in Lovable Cloud secrets, and sandbox testing.

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

Ask [Lovable](https://lovable.dev) for email and it will build something that shows a success toast in the preview. Whether that toast means a delivered message or a published API key is the part you cannot see from there.

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 Lovable project with Lovable Cloud enabled
* Authentication enabled for the app
* 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

Lovable cannot verify your sending domain or create your credentials. Those steps happen in the [dashboard](https://dash.ahasend.com) and are quick: the [quickstart](/docs/quickstart) has both. Scope the key to `messages:send:{your-domain}` rather than `messages:send:all`, so if the key ever leaks the blast radius is one domain's outbound mail instead of your whole account.

## Install the SDK for a Local Check

Install Deno 2.x on your own computer. The `npm:` import in the example below downloads the SDK; no browser package installation is needed.

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

## Store the Key as a Secret

Anything the browser can read, a visitor can read, so the key never goes in frontend code or in a `VITE_`-prefixed variable, which ships to the browser by design.

<Warning>
  If you ever find yourself typing your API key into a variable with a `VITE_` prefix, stop. That one is public. Rotate the key before continuing.
</Warning>

Lovable Cloud is on by default for most workspaces, and it switches itself on the first time you ask for a feature that needs a backend. That gives your app server-side functions and a place to keep secrets. Open **Cloud → Secrets** and add three values:

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

Lovable encrypts these and injects them into your Edge Functions at runtime. They are not part of your frontend bundle, they are not in your project's `.env`, and they do not show up in the published site. That is the whole point.

## Prompt Lovable for a Backend Send

Lovable Cloud runs backend integrations in Edge Functions. Import [`@ahasend/sdk`](https://www.npmjs.com/package/@ahasend/sdk) with a Deno `npm:` specifier, read secrets with `Deno.env.get()`, and pass them to the constructor.

Ask precisely. The words that matter most are *Edge Function*: without them, Lovable may call the email API from the client. Spell out the architecture:

> "When a signed-in user requests their welcome email, send it through AhaSend from an Edge Function, never from the frontend. Authenticate the caller with `withSupabase({ auth: 'user' })`, keep JWT verification enabled, and derive the recipient email and stable idempotency key from the verified user claims rather than request JSON. Import `AhaSendClient` from `npm:@ahasend/sdk`, read `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_SANDBOX` with `Deno.env.get()`, reject missing configuration at startup, check every returned recipient status, and never log recipients, content, secrets, request bodies, or whole errors. The frontend should invoke the Edge Function with the signed-in user's session, not call AhaSend directly."

The function it generates should look close to this:

```ts supabase/functions/send-welcome-email/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;
}

function requireBooleanEnv(name: string): boolean {
  const value = requireEnv(name);
  if (value !== "true" && value !== "false") {
    throw new Error(`${name} must be true or false`);
  }
  return value === "true";
}

const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});
const sandbox = requireBooleanEnv("AHASEND_SANDBOX");
const environment = sandbox ? "sandbox" : "live";

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" } },
      );
    }

    const userId = ctx.userClaims?.id;
    const email = ctx.userClaims?.email;
    if (typeof userId !== "string" || typeof email !== "string") {
      return Response.json({ error: "Authenticated user has no email" }, { status: 400 });
    }

    try {
      const result = await ahasend.messages.send(
        {
          from: { email: "hello@yourdomain.com", name: "Your App" },
          recipients: [{ email }],
          subject: "Welcome aboard",
          html_content: "<p>Thanks for signing up.</p>",
          text_content: "Thanks for signing up.",
          sandbox,
        },
        { idempotencyKey: `sandbox-welcome-${environment}-${userId}` },
      );

      const rejectedCount = result.data.filter((r) => r.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;
    }
  }),
};
```

Check four details in review: secrets are read only in the Edge Function; `withSupabase` authenticates the caller; the recipient and idempotency key come from verified user claims; and `supabase/config.toml` does not disable JWT verification for `send-welcome-email`. The wrapper also handles browser CORS and preflight requests.

AhaSend answers a send with **202** because delivery is asynchronous: the API has accepted and queued your message rather than finished delivering it. The body is multi-status, so `result.data` carries one entry per recipient, and an individual recipient can come back `status: "error"` with a null `id`, a suppressed address for example, while the promise resolves. Check every entry, not just the first. The Edge Function returns its own 202 only after every recipient was accepted.

The stable key protects retries of the same welcome-email operation. Reuse a key only with the exact same payload: AhaSend matches a key against a hash of the request body, so the same key with a changed body is answered `422` rather than replayed. That is why the key carries the environment — the sandbox flag is part of the body, and a key already stored against a sandbox send would reject the first live one.

That key is also the send-rate ceiling on this endpoint. Deriving it from the user id means one account gets one welcome email per idempotency window no matter how many times the button is clicked; the calls after the first replay the stored response instead of mailing again. If Lovable rewrites the key to a fresh UUID per request — a reasonable default in other contexts, and what AhaSend's own [idempotency guide](/docs/api-reference/idempotency) suggests for one-off retries — that ceiling disappears and anyone who can sign up can make your account send on demand. For durable exactly-once behavior beyond the API's idempotency window, atomically claim a welcome-email job in your database and process it from a retryable outbox or worker.

## Verify Where the Key Landed

Lovable's preview will not catch this mistake for you. Use Lovable's code view to confirm these checks.

<Steps>
  <Step title="The key is referenced only inside the Edge Function">
    Via `Deno.env.get()`, and nowhere else. If you see your key, anything starting with `aha-sk-`, or a `VITE_AHASEND...` variable anywhere in the frontend, send Lovable back: "Move the AhaSend call into the Edge Function and remove the key from the frontend entirely." Then rotate the key.
  </Step>

  <Step title="The frontend calls your Edge Function">
    It should invoke the function through the authenticated Supabase client, which sends the user's session. The browser should be talking to your own backend, never directly to `api.ahasend.com`.
  </Step>

  <Step title="The function does not trust recipient data from the browser">
    It must derive the recipient from verified user claims and keep JWT verification enabled. A caller-controlled `email` field turns the function into an email relay even when callers must sign in.
  </Step>
</Steps>

<Tip>
  If you are not comfortable reading the code, ask Lovable directly: "Is my AhaSend API key exposed anywhere in the frontend bundle?" Then verify its answer against the checks above rather than taking the success toast at face value.
</Tip>

## Test in Sandbox Mode

Keep `AHASEND_SANDBOX=true` while you build. In sandbox mode AhaSend runs your message through validation and processing, fires the relevant webhooks, shows it in your dashboard logs, and then stops before delivery. It costs nothing and it cannot email a real customer by accident.

Sandbox also lets you rehearse the unhappy paths. Add `sandbox_result: "bounce"` to the send request and AhaSend simulates a hard bounce so you can see how your app reacts. `"defer"`, `"fail"`, and `"suppress"` cover the other outcomes, and the full list is in the [sandbox mode guide](/docs/send-api/sandbox). Run through them once, confirm you get a clean 202 on the happy path, and only then set `AHASEND_SANDBOX=false`.

Know what that flip costs you. A Lovable project has one Cloud backend and one Secrets store, so the editor preview and the published app read the same `AHASEND_SANDBOX`: turning it off turns real sending on for both at once, including the next time you click the button in the preview. If you want to keep rehearsing after launch, do it on the AhaSend side instead of with this flag — a credential created in sandbox mode simulates every send made with it regardless of the request, so a separate development project holding a sandbox-mode key cannot mail a real customer even if the flag is wrong. The [sandbox mode guide](/docs/send-api/sandbox) covers creating one.

## 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 the Edge Function">
    Confirm the import is server-side and uses the Deno npm specifier exactly as shown: `npm:@ahasend/sdk`. Then ask Lovable to call the function directly and inspect its Edge Function logs.
  </Accordion>

  <Accordion title="401 from api.ahasend.com">
    Re-check **Cloud → Secrets**, confirm the key has a send scope for the `from` domain, and rotate it if it was ever exposed. Secret changes are available to functions without redeploying them.
  </Accordion>

  <Accordion title="The frontend gets 401 from the Edge Function">
    Make sure the user is signed in and the frontend invokes the function through the authenticated Supabase client. Keep JWT verification enabled; do not make the send function public to work around authentication errors.
  </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.