BLOGS

Supabase Send Email Hook: Customize Auth Emails with Edge Functions (2026)

April 15, 2026

Compare dashboard templates, SMTP, and the Send Email Hook. Learn the pros and cons, then wire a minimal Edge Function to send fully customized auth emails.

Supabase Send Email Hook: Customize Auth Emails with Edge Functions (2026)

Motivation

Supabase emails can be done in 3 distinct ways:

  1. The default email setup in Supabase comes with templates for signup confirmations, password resets, magic links, and invites. You can customize these templates in the dashboard.
  2. The default setup is only for testing purposes. At minimum, you should route these templates through an Email API Provider using the SMTP protocol. This gives you the same templates, but with a production-grade Email Provider. This can be done with a few clicks. See Best SMTP Providers for Supabase for a full walkthrough and email provider comparison.
  3. Alternatively, you can replace the entire email logic with your own custom code using Send Email Hook and Edge Functions. Supabase sends a request to your Edge Function when necessary, you handle the email delivery (or even SMS delivery) using an Email API Provider like Pingram. This gives you full control but comes with a bit more complexity.

Comparing the three paths

FactorDashboard templatesDashboard + SMTPAuth hook
Setup timeNoneMinutesA few hours
ComplexityNoneMinimal (SMTP configuration)Complex (writing, deploying and testing edge function code)
CustomizationCan edit text and use built-in fields such as {{ .Email }} or metadata such as {{ .Data.first_name }} if stored at signupSame as dashboard templates; can also customize sender address/domain via SMTPFull customization
Good forQuick tests, light brandingProduction when dashboard templates are enough but you need reliable delivery and limitsProduction + deep customization of the templates
DeliverabilityLimited (for testing only)Production-grade with a reputable Email ProviderProduction-grade with a reputable Email Provider
Third party-RequiredRequired

About Pingram

Pingram supports SMTP (one-click Supabase integration) and auth hooks (call our API from an Edge Function with the pingram package). Our clients report world-class deliverability when they follow our deliverability tips.

Let’s get to it then:

Send Email Hook: minimal HTML + Pingram

When you enable Email Hook in Supabase, and Supabase needs to send an email, it instead sends a request to your function for you to take action on. This is a POST request with a signed JSON body. Your function - which we refer to as the handler - will be responsible for reading the request and doing what needs to be done, e.g. sending a fully customized email.

The JSON body from the request is different depending on the event that triggered the request. For example, when a user signs up, the JSON body includes user (with user_metadata) and email_data (token, token_hash, redirect_to, email_action_type, site_url, and for email changes, token_new / token_hash_new). See the official Send Email Hook docs for the full schema.

Let’s start by creating the edge function that will handle the incoming request. It will do the following:

  1. Verify and extract the data from the JSON body
  2. Send the email using Pingram’s API
  3. Return 200 with {} on success to let Supabase know that the email was handled successfully
supabase functions new email-hook-handler

And update the contents of the function like so:

// FILE: supabase/functions/email-hook-handler/index.ts
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';
import { Pingram } from 'npm:pingram';

const pingram = new Pingram({
  apiKey: Deno.env.get('PINGRAM_API_KEY')!
});

const hookSecret = (Deno.env.get('SEND_EMAIL_HOOK_SECRET') as string).replace(
  'v1,whsec_',
  ''
);

Deno.serve(async (req) => {
  // Only allow POST requests to this function
  if (req.method !== 'POST') {
    return new Response('not allowed', { status: 400 });
  }

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

  // Decode and verify the payload
  const { user, email_data } = wh.verify(payload, headers) as {
    user: {
      id: string;
      email: string;
      new_email?: string;
      user_metadata: Record<string, string>;
    };
    email_data: {
      token: string;
      token_new?: string;
      email_action_type: string;
    };
  };

  // Get the type of event that triggered this email request
  const action = email_data.email_action_type;

  // user's name and the token shown in emails for use in the HTML templates below
  const name = user.user_metadata?.first_name ?? 'there';
  const token = email_data.token;

  let subject: string;
  let html: string;

  if (action === 'signup') {
    subject = 'Confirm your email';
    html = `<p>Hi ${name}, your code is <strong>${token}</strong></p>`;
  } else if (action === 'recovery') {
    subject = 'Reset your password';
    html = `<p>Hi ${name}, use this code to reset: <strong>${token}</strong></p>`;
  } else if (action === 'magiclink') {
    subject = 'Your login code';
    html = `<p>Hi ${name}, sign in with <strong>${token}</strong></p>`;
  } else if (action === 'invite') {
    subject = "You're invited";
    html = `<p>Hi ${name}, your invite code is <strong>${token}</strong></p>`;
  } else if (action === 'reauthentication') {
    subject = 'Your verification code';
    html = `<p>Hi ${name}, your code is <strong>${token}</strong></p>`;
  } else if (action === 'email_change') {
    //
    // This looks weird, explained below!
    //
    await pingram.send({
      type: 'email_change_current',
      to: { id: user.id, email: user.email },
      email: {
        subject: 'Confirm email change',
        html: `<p>Your code: <strong>${email_data.token}</strong></p>`
      }
    });
    if (email_data.token_new && user.new_email) {
      await pingram.send({
        type: 'email_change_new',
        to: { id: user.id, email: user.new_email },
        email: {
          subject: 'Confirm your new email',
          html: `<p>Your code: <strong>${email_data.token_new}</strong></p>`
        }
      });
    }

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

  await pingram.send({
    type: action,
    to: { id: user.id, email: user.email },
    email: { subject, html }
  });

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

Supabase’s documented email_action_type values are handled explicitly above: signup, recovery, magiclink, invite, and reauthentication each get one send.

email_change gets two sends and an early return because it needs to send two emails instead of one (one to the current email and one to the new email).

Deploying and enabling the hook

You need the Edge Function’s HTTPS URL before you can register the hook, and you need the Generate Secret value from the dashboard before SEND_EMAIL_HOOK_SECRET is correct. Order: deploy → enable hook (get secret) → set secrets.

1. Deploy the function

supabase functions deploy email-hook-handler --no-verify-jwt

--no-verify-jwt is required because Auth calls the function server-side — there is no user JWT.

Use the deployed URL (for example https://<project-ref>.supabase.co/functions/v1/email-hook-handler) in the next step.

2. Enable the hook

In the Supabase Dashboard: Authentication > HooksSend EmailHTTPS → paste that function URL → Generate Secret. Copy the value Supabase shows — that string is what you store as SEND_EMAIL_HOOK_SECRET (including the v1,whsec_ prefix if your verifier expects it).

While the hook is on, Supabase does not send auth mail itself; your function must send (here via Pingram). Keep the Email Provider enabled so signup flows that need email stay allowed.

3. Set secrets

supabase secrets set PINGRAM_API_KEY=pingram_sk_...
supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_<paste-from-dashboard>"

After secrets update, new invocations of the function pick them up (no redeploy required for the secret alone, unless you change code).

Resources