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

# How to Send Email from a Lovable App

> Add transactional email to a Lovable app: send from a Supabase Edge Function with the AhaSend SDK, with your key kept in Lovable Cloud secrets.

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.

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

## 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";

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

* **Templating**: pass `substitutions` per recipient and use `{{ variable }}` in the subject or body.
* **Batch sends**: `recipients` accepts up to 100 entries, each of which gets its own message.
* **Webhooks**: AhaSend signs every webhook with the Standard Webhooks scheme, and `WebhookVerifier` from `@ahasend/sdk/webhooks` verifies it on Deno. A receiver arrives with no user session, so it needs `auth: "none"` plus `verify_jwt = false` under `[functions.<name>]` in `supabase/config.toml` — the one place in this guide where a public function is correct, because the signature becomes the only thing authenticating the caller. That also makes its body attacker-controlled, so read it through the module's bounded adapter rather than buffering whatever arrives: `const handle = nextRouteHandler(verifier, callback, { maxBodyBytes: 1_000_000 })` takes a web-standard `Request` despite the name, so `withSupabase({ auth: "none" }, (req) => handle(req))` wires it up. It streams the signed bytes, stops above your limit, and calls your callback only once the signature checks out. Verification covers those exact bytes, so re-serializing `await req.json()` changes them and the check will never pass. A valid signature still does not make a delivery unique: before the handler does anything durable, commit the verified `webhook-id` in the same database transaction as that work, await that commit before you return, and answer repeats with a 2xx. The [Cloudflare Workers guide](/docs/guides/cloudflare-workers) shows the same pattern on another edge runtime.
* **Attachments, scheduling, tags**: all available on the same endpoint. See the [API reference](/docs/api-reference).
* **Moving the send to a Node server**: the [Express guide](/docs/guides/express) covers the Node imports, environment access, and route integration.
* Building in a different AI tool? See [v0](/docs/guides/v0) and [Bolt.new](/docs/guides/bolt-new).

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