> ## Documentation Index
> Fetch the complete documentation index at: https://loops.so/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Supabase - Send emails

> Send every Supabase Auth email through Loops using the Send Email Hook, with one Loops template per auth email type.

<Tip>
  This guide only covers auth emails. To sync Supabase users into your Loops
  audience and trigger workflows, see our [Supabase contact sync guide](/docs/integrations/supabase/contact-sync).
</Tip>

Supabase Auth's [Send Email Hook](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook) lets you send emails via an HTTP endpoint you control rather than using Supabase's own sender. This guide shows how to point that hook at Loops, with a Supabase Edge Function that verifies the request and sends the matching Loops transactional email.

<Info>
  This is the recommended way to send Supabase authentication emails with
  Loops. Looking for something simpler to set up? See our [Supabase SMTP
  guide](/docs/smtp/supabase).
</Info>

## How it works

<Steps>
  <Step title="A user triggers an auth email">
    For example, they sign up, request a magic link, or ask to reset their
    password.
  </Step>

  <Step title="Supabase calls your Edge Function">
    Instead of sending the email itself, Supabase POSTs a signed payload
    containing the user and an `email_action_type` (like `signup` or
    `recovery`) to your Send Email Hook endpoint.
  </Step>

  <Step title="Your function sends the matching Loops email">
    The function verifies the request signature, maps the `email_action_type`
    to a Loops transactional email, and sends it through the [Loops
    API](/docs/api-reference/send-transactional-email) with the token and links
    Supabase provided.
  </Step>
</Steps>

## 1. Create transactional emails in Loops

[Create a transactional email](/docs/transactional) in Loops for each Supabase auth email type you want to send. You don't need to create all of them — only create templates for the action types you use, and any action type without a matching template will fall back to Supabase's default sender (see [step 3](#3-write-the-edge-function)).

| Loops template                               | `email_action_type`                  | Suggested data variables                                                  |
| :------------------------------------------- | :----------------------------------- | :------------------------------------------------------------------------ |
| Confirm signup                               | `signup`                             | `token`, `tokenHash`, `redirectTo`, `siteUrl`                             |
| Invite user                                  | `invite`                             | `token`, `tokenHash`, `redirectTo`, `siteUrl`                             |
| Magic link                                   | `magiclink`                          | `token`, `tokenHash`, `redirectTo`, `siteUrl`                             |
| Reset password                               | `recovery`                           | `token`, `tokenHash`, `redirectTo`, `siteUrl`                             |
| Change email address                         | `email_change`                       | `token`, `tokenHash`, `tokenNew`, `tokenHashNew`, `redirectTo`, `siteUrl` |
| Reauthentication                             | `reauthentication`                   | `token`, `tokenHash`                                                      |
| Security notification: password changed      | `password_changed_notification`      | —                                                                         |
| Security notification: email changed         | `email_changed_notification`         | `oldEmail`                                                                |
| Security notification: phone changed         | `phone_changed_notification`         | `oldPhone`                                                                |
| Security notification: identity linked       | `identity_linked_notification`       | `provider`                                                                |
| Security notification: identity unlinked     | `identity_unlinked_notification`     | `provider`                                                                |
| Security notification: MFA factor enrolled   | `mfa_factor_enrolled_notification`   | `factorType`                                                              |
| Security notification: MFA factor unenrolled | `mfa_factor_unenrolled_notification` | `factorType`                                                              |

<Tip>
  Supabase occasionally adds new `email_action_type` values. Check the [Send
  Email Hook
  docs](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook) for
  the current list before going live.
</Tip>

For `token`, add a `{token}` data variable to show a 6-digit OTP code. For a clickable link instead, build a URL data variable using `{siteUrl}`, `{tokenHash}` and `{redirectTo}`, for example:

```
{siteUrl}/auth/confirm?token_hash={tokenHash}&type=signup&next={redirectTo}
```

After publishing each email, copy its **Transactional ID** from the Review page — you'll need it in the next step.

## 2. Add environment variables

You'll need a Loops API key and the transactional IDs from the previous step. Create an API key from [API Settings](https://app.loops.so/settings?page=api).

```bash .env theme={"dark"}
LOOPS_API_KEY=replace-with-your-loops-api-key
SEND_EMAIL_HOOK_SECRET=replace-with-your-hook-secret

LOOPS_SIGNUP_TRANSACTIONAL_ID=replace-with-transactional-id
LOOPS_INVITE_TRANSACTIONAL_ID=replace-with-transactional-id
LOOPS_MAGICLINK_TRANSACTIONAL_ID=replace-with-transactional-id
LOOPS_RECOVERY_TRANSACTIONAL_ID=replace-with-transactional-id
LOOPS_EMAIL_CHANGE_TRANSACTIONAL_ID=replace-with-transactional-id
LOOPS_REAUTHENTICATION_TRANSACTIONAL_ID=replace-with-transactional-id
```

<Tip>
  Only add variables for the templates you created in step 1. You'll generate
  `SEND_EMAIL_HOOK_SECRET` from the Supabase dashboard in [step
  4](#4-enable-the-hook-in-supabase).
</Tip>

## 3. Write the Edge Function

Create a new Supabase Edge Function:

```bash theme={"dark"}
supabase functions new send-email
```

Supabase signs each request using the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so verify it with the `standardwebhooks` library before sending anything. This function maps `email_action_type` to a Loops transactional ID and sends the email with the Loops SDK.

```typescript supabase/functions/send-email/index.ts theme={"dark"}
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
import { LoopsClient } from "npm:loops";

const loops = new LoopsClient(Deno.env.get("LOOPS_API_KEY") as string);
const hookSecret = (Deno.env.get("SEND_EMAIL_HOOK_SECRET") as string).replace(
  "v1,whsec_",
  ""
);

// Map each Supabase email_action_type to a Loops transactional email ID.
// Leave out any action type you haven't created a Loops template for.
const TRANSACTIONAL_IDS: Record<string, string | undefined> = {
  signup: Deno.env.get("LOOPS_SIGNUP_TRANSACTIONAL_ID"),
  invite: Deno.env.get("LOOPS_INVITE_TRANSACTIONAL_ID"),
  magiclink: Deno.env.get("LOOPS_MAGICLINK_TRANSACTIONAL_ID"),
  recovery: Deno.env.get("LOOPS_RECOVERY_TRANSACTIONAL_ID"),
  email_change: Deno.env.get("LOOPS_EMAIL_CHANGE_TRANSACTIONAL_ID"),
  reauthentication: Deno.env.get("LOOPS_REAUTHENTICATION_TRANSACTIONAL_ID"),
};

type EmailData = {
  token: string;
  token_hash: string;
  token_new: string;
  token_hash_new: string;
  redirect_to: string;
  email_action_type: string;
  site_url: string;
  old_email?: string;
};

Deno.serve(async (req) => {
  if (req.method !== "POST") {
    return new Response("not allowed", { status: 400 });
  }

  const payload = await req.text();
  const headers = Object.fromEntries(req.headers);
  const wh = new Webhook(hookSecret);

  try {
    const { user, email_data } = wh.verify(payload, headers) as {
      user: { email: string };
      email_data: EmailData;
    };

    const transactionalId = TRANSACTIONAL_IDS[email_data.email_action_type];

    // No Loops template mapped for this action type — let Supabase's
    // built-in sender handle it instead of failing the request.
    if (!transactionalId) {
      return new Response(JSON.stringify({}), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      });
    }

    const response = await loops.sendTransactionalEmail({
      transactionalId,
      email: user.email,
      dataVariables: {
        token: email_data.token,
        tokenHash: email_data.token_hash,
        tokenNew: email_data.token_new ?? "",
        tokenHashNew: email_data.token_hash_new ?? "",
        redirectTo: email_data.redirect_to,
        siteUrl: email_data.site_url,
      },
    });

    if (!response.success) {
      throw new Error(response.message ?? "Failed to send Loops transactional email");
    }
  } catch (error) {
    return new Response(
      JSON.stringify({
        error: {
          http_code: 500,
          message: error instanceof Error ? error.message : "Unknown error",
        },
      }),
      { status: 401, headers: { "Content-Type": "application/json" } }
    );
  }

  return new Response(JSON.stringify({}), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
});
```

<Warning>
  Only include data variables in the request that exist on that specific
  template. If a template doesn't have `tokenNew` or `tokenHashNew` added as
  data variables (all types other than `email_change`), it's fine to send
  them anyway — Loops ignores values that don't match a variable on the
  template.
</Warning>

Deploy the function and push your secrets:

```bash theme={"dark"}
supabase secrets set --env-file .env
supabase functions deploy send-email --no-verify-jwt
```

## 4. Enable the hook in Supabase

In your Supabase project, go to **Authentication -> Hooks** and add a new **Send Email** hook.

Select **HTTPS** as the hook type and paste in your deployed Edge Function URL (find it in **Edge Functions** after deploying).

Supabase will generate a signing secret in the form `v1,whsec_...` — copy it into `SEND_EMAIL_HOOK_SECRET` and re-run `supabase secrets set` so the deployed function has the matching value.

## Testing the hook

Create a test user from **Authentication -> Users -> Add user**, then trigger an email (for example, click **Send magic link** on that user).

Check the [Loops Transactional page](https://app.loops.so/transactional) for the send, and Supabase's **Logs -> Auth** logs (search for "magiclink" or "signup") if nothing arrives. A `401` in the auth logs usually means `SEND_EMAIL_HOOK_SECRET` doesn't match the secret shown on the Hooks page.

## Keep templates in sync from code

Because auth templates are just transactional emails, you can update them programmatically with the [Update an email message API](/docs/api-reference/update-email-message) instead of editing them by hand. Pass `expectedRevisionId` on every update so a stale edit doesn't silently overwrite someone else's change — the API returns `409 Conflict` if the revision has moved on since you last fetched it.

```typescript theme={"dark"}
import { LoopsClient, APIError } from "loops";

const loops = new LoopsClient(process.env.LOOPS_API_KEY as string);

async function updateAuthTemplate(
  transactionalId: string,
  updates: { subject?: string; previewText?: string; lmx?: string }
) {
  const draft = await loops.ensureTransactionalDraft(transactionalId);

  try {
    await loops.updateEmailMessage(draft.draftEmailMessageId as string, {
      expectedRevisionId: draft.draftEmailMessageContentRevisionId,
      ...updates,
    });
  } catch (error) {
    if (error instanceof APIError && error.statusCode === 409) {
      // Someone else edited this draft after we fetched it — refetch the
      // current revision and retry once with it.
      const current = await loops.getEmailMessage(draft.draftEmailMessageId as string);
      await loops.updateEmailMessage(draft.draftEmailMessageId as string, {
        expectedRevisionId: current.contentRevisionId,
        ...updates,
      });
    } else {
      throw error;
    }
  }

  await loops.publishTransactionalEmail(transactionalId);
}
```

This is useful for keeping subject lines, preview text, or LMX content in version control and rolling changes out with a script or CI job instead of the editor. See more [email message API examples](/docs/api-reference/examples/transactional-emails).

## Send Email Hook vs Loops SMTP

|           | Send Email Hook (this guide)                                             | [Loops SMTP](/docs/smtp/supabase)                                      |
| :-------- | :----------------------------------------------------------------------- | :---------------------------------------------------------------- |
| Setup     | Deploy an Edge Function                                                  | A few clicks in Loops settings                                    |
| Templates | One Loops template per action type, edited or synced via the Content API | Shared payload structure pasted into each Supabase template field |
| Coverage  | All Send Email Hook action types, including security notifications       | Confirm signup, Magic link, Invite, Change email, Reset password  |
| Best for  | Teams that want full API/LMX control over every auth email               | Getting Supabase auth emails into Loops quickly                   |

Both approaches use the same [transactional emails](/docs/transactional) you create in Loops, so you can start with SMTP and move to the Send Email Hook later without rebuilding your templates.

## Bring in the rest of the lifecycle

The Send Email Hook only covers auth emails. Combine it with our [Supabase contact sync guide](/docs/integrations/supabase/contact-sync) to sync users to your Loops audience and trigger onboarding, activation, or plan-change workflows from the same `auth.users` events:

1. Use this guide's Send Email Hook for confirm signup, magic link, invite, reset password, and security notification emails.
2. Use the [Supabase contact sync](/docs/integrations/supabase/contact-sync#create-a-database-hook-in-supabase) database webhook to create or update Loops contacts on `INSERT` and `UPDATE` of `auth.users`.
3. Build a [workflow](/docs/workflows) triggered by the `INSERT` event to send a welcome or onboarding sequence once a user signs up.

<Tip>
  Setting this up with a coding agent? Give it this prompt:

  ```
  Use Supabase Auth and Loops for all user lifecycle email. Create Loops
  templates for signup, invite, magic link, and reset password, then wire up
  a Supabase Send Email Hook Edge Function to send them
  (https://loops.so/docs/integrations/supabase/send-email). Use the Supabase contact
  sync guide's database webhook to sync users to Loops and trigger a
  welcome workflow (https://loops.so/docs/integrations/supabase/contact-sync).
  ```
</Tip>

## Troubleshooting

If your emails are not sending:

* Confirm each transactional email in Loops is **Published**.
* Confirm `SEND_EMAIL_HOOK_SECRET` matches the secret shown on the Hooks page in Supabase, including the `v1,whsec_` prefix before it's stripped in code.
* Check that `email_action_type` in the incoming payload matches a key in `TRANSACTIONAL_IDS` — unmapped types are silently skipped rather than erroring.
* Check Supabase's **Logs -> Auth** for the response your function returned.
* Check that your Loops sending domain is verified.

## Read more

<CardGroup cols={2}>
  <Card title="Send transactional email" icon="envelope" href="/docs/api-reference/send-transactional-email">
    Loops API endpoint used to send transactional emails.
  </Card>

  <Card title="Update email message" icon="file-lines" href="/docs/api-reference/update-email-message">
    Sync template content programmatically with revision-safe updates.
  </Card>

  <Card title="Supabase contact sync" href="/docs/integrations/supabase/contact-sync" icon="arrows-turn-right">
    Sync contacts and trigger workflows from Supabase events.
  </Card>

  <Card title="Supabase SMTP" href="/docs/smtp/supabase" icon="bolt">
    A simpler setup for sending Supabase auth emails through Loops.
  </Card>
</CardGroup>


## Related topics

- [Supabase SMTP](/docs/smtp/supabase.md)
- [Integrations](/docs/integrations.md)
- [Send emails from Bolt.new](/docs/guides/bolt-emails.md)
- [Send a transactional email](/docs/api-reference/send-transactional-email.md)
- [Send a preview of an email message](/docs/api-reference/preview-email-message.md)
