Supabase Auth emails via Sendry
Replace Supabase's default Auth emails (password reset, magic link, signup confirmation) with Sendry using Send Email Hooks.
Supabase's built-in SMTP works for prototypes but doesn't scale — IP reputation is shared, throughput is capped, and you can't customize the templates beyond raw HTML. This recipe wires Sendry into Supabase Auth so every transactional email (password reset, magic link, signup confirmation, email change, reauthentication, invite) is delivered through your verified Sendry domain.
We use Supabase's Send Email Hook — an HTTPS webhook Supabase calls instead of its internal SMTP whenever an auth email needs to be sent.
What you'll build
- A small HTTPS endpoint that receives Supabase's auth payload, renders an email, and ships it via Sendry.
- A Supabase Auth Hook pointed at that endpoint.
- (Optional) Per-email-type templates in Sendry's visual editor.
Prerequisites
- A Sendry account with at least one verified sending domain.
- A Supabase project on the Pro plan or higher (Send Email Hook is a Pro feature).
- An HTTPS endpoint where you can host the receiver (Vercel, Cloudflare Workers, Fly, Render, etc.).
- A Sendry API key with
sending_accessscope — store asSENDRY_API_KEY.
Step 1: Create the hook receiver
This example uses a Next.js Route Handler, but the shape works in any HTTP framework.
// app/api/auth-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Sendry } from "sendry-sdk";
import { Webhook } from "standardwebhooks";
const sendry = new Sendry(process.env.SENDRY_API_KEY!);
const webhook = new Webhook(process.env.SUPABASE_HOOK_SECRET!);
interface AuthHookPayload {
user: { email: string; user_metadata?: Record<string, unknown> };
email_data: {
token: string;
token_hash: string;
redirect_to: string;
email_action_type:
| "signup"
| "login"
| "magiclink"
| "recovery"
| "invite"
| "email_change"
| "reauthentication";
site_url: string;
};
}
export async function POST(req: NextRequest) {
const body = await req.text();
const headers = Object.fromEntries(req.headers);
// Supabase signs each hook delivery — verify before trusting the payload.
let payload: AuthHookPayload;
try {
payload = webhook.verify(body, headers) as AuthHookPayload;
} catch {
return NextResponse.json({ error: "invalid signature" }, { status: 401 });
}
const { user, email_data } = payload;
const { subject, html, text } = renderTemplate(email_data, user);
await sendry.emails.send({
from: "Acme <auth@acme.com>",
to: user.email,
subject,
html,
text,
tags: [
{ name: "kind", value: "auth" },
{ name: "action", value: email_data.email_action_type },
],
});
return NextResponse.json({ ok: true });
}
function renderTemplate(
email_data: AuthHookPayload["email_data"],
user: AuthHookPayload["user"],
) {
const confirmUrl = `${email_data.site_url}/auth/confirm?token_hash=${email_data.token_hash}&type=${email_data.email_action_type}&next=${encodeURIComponent(
email_data.redirect_to,
)}`;
switch (email_data.email_action_type) {
case "signup":
return {
subject: "Confirm your email",
html: `<p>Welcome! Click below to confirm your email and finish signup.</p>
<p><a href="${confirmUrl}">Confirm email</a></p>
<p>Or paste this 6-digit code: <strong>${email_data.token}</strong></p>`,
text: `Welcome! Confirm your email: ${confirmUrl} (code: ${email_data.token})`,
};
case "recovery":
return {
subject: "Reset your password",
html: `<p>Click below to choose a new password. This link expires in 1 hour.</p>
<p><a href="${confirmUrl}">Reset password</a></p>`,
text: `Reset your password: ${confirmUrl}`,
};
case "magiclink":
case "login":
return {
subject: "Sign in to Acme",
html: `<p>Click below to sign in. This link expires in 1 hour.</p>
<p><a href="${confirmUrl}">Sign in</a></p>`,
text: `Sign in: ${confirmUrl}`,
};
case "email_change":
return {
subject: "Confirm your new email",
html: `<p>Click below to confirm your new email address.</p>
<p><a href="${confirmUrl}">Confirm new email</a></p>`,
text: `Confirm new email: ${confirmUrl}`,
};
case "invite":
return {
subject: "You've been invited",
html: `<p>You've been invited to join Acme. Accept the invite below.</p>
<p><a href="${confirmUrl}">Accept invite</a></p>`,
text: `Accept invite: ${confirmUrl}`,
};
case "reauthentication":
return {
subject: "Confirm it's you",
html: `<p>Your code is: <strong>${email_data.token}</strong></p>`,
text: `Your code: ${email_data.token}`,
};
}
}
Install the dependencies:
bun add sendry-sdk standardwebhooks
Step 2: Configure the hook in Supabase
- Open your Supabase project → Authentication → Hooks.
- Click Add Hook → Send Email Hook.
- Set the URL to your deployed endpoint (e.g.
https://yourapp.com/api/auth-email). - Choose HTTPS and copy the generated Webhook Secret — save it as
SUPABASE_HOOK_SECRETin your hosting environment. - Click Save.
Once the hook is enabled, Supabase stops sending its own emails and calls your endpoint instead. Test by triggering a password reset from your app.
Step 3 (optional): Move templates into Sendry
If you'd rather not hardcode HTML, render Sendry templates by id:
await sendry.emails.send({
from: "Acme <auth@acme.com>",
to: user.email,
subject,
template_id: "tmpl_pwd_reset",
template_data: {
confirm_url: confirmUrl,
user_name: user.user_metadata?.full_name ?? "there",
},
});
Manage the templates in Sendry → Templates (or via the visual editor).
Going to production
- Reply-To and Postmark/
List-Unsubscribe: auth emails are transactional — don't setList-Unsubscribe. Add aReply-Toif you'd like users to reply with support requests. - Bounces: hook this endpoint into Sendry's webhook for
email.bouncedto mark accounts and surface bad addresses in your admin UI. - Rate limits: Supabase rate-limits auth email triggers per project. Set a sensible cap in Authentication → Rate Limits to avoid abuse hammering your Sendry quota.
- Deliverability: send from a dedicated subdomain (e.g.
auth.acme.com) so transactional reputation is isolated from marketing.
Troubleshooting
- 401 from your endpoint: verify
SUPABASE_HOOK_SECRETmatches exactly; rotate it in Supabase and your environment together. - Emails not sending: check Supabase Logs → Auth for hook delivery attempts, then check Sendry Logs → API for the corresponding
POST /v1/emails. - Stuck in Supabase's old templates: confirm the hook is Enabled and SMTP is not also configured — Supabase prefers the hook when both are present.