Guides / Supabase auth emails

How to send Supabase auth emails with your own email API

Short answer: Turn on Supabase's Send Email Hook and point it at an Edge Function. Supabase sends the function the user and a one-time token, the function builds the link and sends the email through SendFox's API. SendFox doesn't offer SMTP, so the hook is the way in. Free for 3,000 emails a month.

Get a free API key

Supabase's built-in email sender is for testing: it's rate limited and sends from Supabase's domain. For production you want auth emails from your own domain with delivery logs, and that's what this sets up.

Step 1: Get an API key

Sign up free and open Transactional. Your key is created on the first visit.

Step 2: Write the Edge Function

It checks the hook's signature, builds the confirmation link from token_hash, and sends one email. The six-digit token goes in the email too, for apps that sign in with a code.

supabase/functions/send-email/index.ts

import { Webhook } from 'https://esm.sh/[email protected]';

const hookSecret = Deno.env.get('SEND_EMAIL_HOOK_SECRET')!.replace('v1,whsec_', '');
const subjects: Record<string, string> = {
  signup: 'Confirm your email',
  recovery: 'Reset your password',
  magiclink: 'Your sign-in link',
  invite: 'You have been invited',
  email_change: 'Confirm your new email',
  reauthentication: 'Your verification code',
};
// Click tracking can never be turned on for these tags, so scanners can't burn the link.
const tags: Record<string, string> = {
  signup: 'verify_email',
  recovery: 'password_reset',
  magiclink: 'magic_link',
  email_change: 'verify_email',
  reauthentication: 'otp',
};

Deno.serve(async (req) => {
  const payload = await req.text();
  const { user, email_data } = new Webhook(hookSecret).verify(
    payload,
    Object.fromEntries(req.headers),
  ) as { user: { email: string; new_email?: string }; email_data: Record<string, string> };

  const type = email_data.email_action_type;

  // A secure email change sends two emails. Supabase's naming is inverted here:
  // token_hash_new goes to the current address, token_hash to the new one.
  const emails = type === 'email_change' && user.new_email
    ? [
        { to: user.email, tokenHash: email_data.token_hash_new, token: email_data.token_new },
        { to: user.new_email, tokenHash: email_data.token_hash, token: email_data.token },
      ]
    : [{ to: user.email, tokenHash: email_data.token_hash, token: email_data.token }];

  for (const email of emails.filter((e) => e.tokenHash)) {
    const link = `${Deno.env.get('SUPABASE_URL')}/auth/v1/verify?token=${email.tokenHash}`
      + `&type=${type}&redirect_to=${encodeURIComponent(email_data.redirect_to)}`;

    const res = await fetch('https://api.sendfox.com/transactional/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${Deno.env.get('SENDFOX_API_KEY')}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': `supabase-${email.tokenHash}`,
      },
      body: JSON.stringify({
        from: 'Acme <[email protected]>',
        to: [email.to],
        subject: subjects[type] ?? 'Your Acme account',
        html: `<p><a href="${link}">Continue</a></p><p>Or enter this code: <strong>${email.token}</strong></p>`,
        tag: tags[type] ?? 'auth',
      }),
    });

    if (!res.ok) {
      const error = await res.json();
      return Response.json({ error: { http_code: res.status, message: `${error.name}: ${error.fix}` } }, { status: 500 });
    }
  }

  return Response.json({});
});

Supabase changes hook details now and then, especially for email changes. If anything here disagrees with Supabase's Send Email Hook docs, follow theirs for the hook side; the SendFox call stays the same.

Step 3: Deploy it and set secrets

The hook authenticates with its own signature, not a user JWT, so deploy without JWT verification:

supabase functions deploy send-email --no-verify-jwt
supabase secrets set SENDFOX_API_KEY=your_sendfox_key
supabase secrets set SEND_EMAIL_HOOK_SECRET='v1,whsec_...'

Step 4: Turn on the hook

In the Supabase dashboard, open Authentication > Hooks, add a Send Email hook of type HTTPS, and paste your function's URL. Supabase generates the hook secret there; that's the value for SEND_EMAIL_HOOK_SECRET.

Step 5: Test, then verify your domain

Send to [email protected] first. Test addresses never reach a mailbox and never count against your quota, and [email protected] and [email protected] fake those outcomes so you can see your error handling and webhook fire. Until your domain is verified, send from [email protected], which delivers to the test addresses and your own account email.

Then add your domain under Account > Domains, paste the DNS records into your DNS provider and click verify. After that, send from any address on it.

Things to know

Full reference: Transactional API docs, or the same docs as Markdown to hand to your coding agent.

FAQ

Why use the Send Email Hook instead of custom SMTP?

Supabase can send auth emails through custom SMTP or through the Send Email Hook. SendFox does not offer SMTP, so use the hook: Supabase calls your function with the user and the token, and your function sends the email over HTTPS.

Do I still hit Supabase's built-in email rate limit?

The built-in sender is meant for testing and is tightly limited. With the hook, your own provider sends the email, so the limits are your SendFox plan limits instead.

Which emails does the hook cover?

Every auth email: signup confirmation, magic link, password recovery, invite, email change and reauthentication. The email_action_type field says which one.

How much does it cost?

Free for 3,000 emails a month. $15 a month for 50,000.

Send your first email in two minutes.

Free for 3,000 emails a month. $15 a month for 50,000. Already on Resend? Change two environment variables.

Get your free API key

See everything the email API does