BLOGS
Supabase Send Email Hook: Customize Auth Emails with Edge Functions (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.
Motivation
Supabase emails can be done in 3 distinct ways:
- 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.
- 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.
- 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
| Factor | Dashboard templates | Dashboard + SMTP | Auth hook |
|---|---|---|---|
| Setup time | None | Minutes | A few hours |
| Complexity | None | Minimal (SMTP configuration) | Complex (writing, deploying and testing edge function code) |
| Customization | Can edit text and use built-in fields such as {{ .Email }} or metadata such as {{ .Data.first_name }} if stored at signup | Same as dashboard templates; can also customize sender address/domain via SMTP | Full customization |
| Good for | Quick tests, light branding | Production when dashboard templates are enough but you need reliable delivery and limits | Production + deep customization of the templates |
| Deliverability | Limited (for testing only) | Production-grade with a reputable Email Provider | Production-grade with a reputable Email Provider |
| Third party | - | Required | Required |
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:
- Verify and extract the data from the JSON body
- Send the email using Pingram’s API
- Return
200with{}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 > Hooks → Send Email → HTTPS → 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
- Supabase Send Email Hook docs
- Send Email from Supabase Edge Functions — on-demand, database webhook, and auth hook patterns
- Best SMTP Providers for Supabase — SMTP path and production sending
- Supabase Auth Emails Not Arriving? — deliverability
- Pingram — email API used in the examples above