How to Send Supabase Auth Emails with Sently
Supabase's built-in email sender is meant for testing and only allows a few emails an hour. Send your signup confirmations, magic links and password resets through Sently instead, from your own domain, using Supabase's Send Email Hook.
💡 Direct Solution (Executive Summary)
Supabase lets you replace its built-in email sender with a Send Email Hook: a small function that Supabase calls every time it needs to send an auth email. Create a Supabase Edge Function that calls Sently's API, then turn it on under Authentication → Auth Hooks in your Supabase dashboard. Supabase stops using its built-in sender, and every auth email goes out through Sently from your own domain.
1 Add and Verify Your Domain in Sently
Auth emails must come from a domain you own, such as yourdomain.com or auth.yourdomain.com:
- In your Sently dashboard, open Domains and click Add domain.
- Add the DNS records Sently shows you at your DNS provider (Cloudflare, Namecheap, GoDaddy and so on). The DMARC record is recommended but optional.
- Click Check now. Sently also keeps checking for you, and the domain turns verified once the records are found.
2 Create a Sently API Key
Open API keys and click Create key. Choose live mode and the send access level: the function only needs to send. Copy the key (it starts with sk_live_). It is shown once.
3 Create the Edge Function
With the Supabase CLI, create the function and store your Sently key and sender address as secrets. The sender must be on the domain you verified in step 1:
supabase functions new send-email
supabase secrets set \
SENTLY_API_KEY="sk_live_..." \
SENTLY_FROM="Acme <[email protected]>"Replace the contents of supabase/functions/send-email/index.ts with this. It checks the request really came from Supabase, builds the confirmation link, and sends the email through Sently:
// supabase/functions/send-email/index.ts
import { Webhook } from 'https://esm.sh/[email protected]';
const SENTLY_API_KEY = Deno.env.get('SENTLY_API_KEY')!;
const FROM = Deno.env.get('SENTLY_FROM')!;
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
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',
invite: 'You have been invited',
magiclink: 'Your sign-in link',
email_change: 'Confirm your new email address',
reauthentication: 'Your verification code',
};
async function send(to: string, type: string, token: string, tokenHash: string, redirectTo: string, key: string) {
const link = `${SUPABASE_URL}/auth/v1/verify?token=${tokenHash}&type=${type}&redirect_to=${encodeURIComponent(redirectTo)}`;
const html = type === 'reauthentication'
? `<p>Your verification code is <strong>${token}</strong>.</p>`
: `<p><a href="${link}">Click here to continue</a></p><p>Or enter this code: <strong>${token}</strong></p>`;
const res = await fetch('https://sently.in/api/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${SENTLY_API_KEY}`,
'Content-Type': 'application/json',
// Supabase retries a hook that fails. The same key means a retry
// never sends the same email twice.
'Idempotency-Key': key,
},
body: JSON.stringify({ from: FROM, to: [to], subject: SUBJECTS[type] ?? 'Your verification email', html }),
});
if (!res.ok) throw new Error(`Sently returned ${res.status}: ${await res.text()}`);
}
Deno.serve(async (req) => {
const payload = await req.text();
const headers = Object.fromEntries(req.headers);
try {
// Proves the request really came from your Supabase project.
const { user, email_data } = new Webhook(hookSecret).verify(payload, headers) as {
user: { email: string; new_email?: string };
email_data: {
email_action_type: string; token: string; token_hash: string;
token_new: string; token_hash_new: string; redirect_to: string;
};
};
const { email_action_type: type, token, token_hash, token_new, token_hash_new, redirect_to } = email_data;
const id = headers['webhook-id'] ?? crypto.randomUUID();
if (type === 'email_change' && token_hash_new) {
// Secure email change: one email to each address. Supabase's field
// names are reversed here on purpose (see its docs).
await send(user.email, type, token, token_hash_new, redirect_to, `${id}-current`);
await send(user.new_email!, type, token_new, token_hash, redirect_to, `${id}-new`);
} else {
const to = type === 'email_change' ? user.new_email! : user.email;
await send(to, type, token || token_new, token_hash, redirect_to, id);
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
return new Response(
JSON.stringify({ error: { http_code: 500, message: String(error) } }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
});Deploy it. Supabase signs the request itself, so the function does not need a user's login token:
supabase functions deploy send-email --no-verify-jwt4 Turn On the Send Email Hook in Supabase
- In your Supabase dashboard, go to Authentication → Auth Hooks and add a Send Email hook.
- Choose HTTPS and enter your function's URL:
https://<project-ref>.supabase.co/functions/v1/send-email. - Click Generate secret, copy it, and save the hook.
- Store the secret for your function:
supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_..."From now on Supabase sends no auth emails itself. Every signup confirmation, magic link, invite, password reset and email change goes through your function and Sently.
5 Test It and Check Your Rate Limits
- Sign up in your app with a real address, or trigger a password reset. The email should arrive from your own domain within seconds.
- Open Emails in your Sently dashboard. Every auth email is listed with its status, so you can see when it was delivered or if it bounced.
- Supabase's own auth rate limits still apply. If you expect many signups at once, raise them under Authentication → Rate Limits.
- If nothing arrives, check the function's logs in the Supabase dashboard. A message such as
Sently returned 403usually means the sender address is not on a verified domain.
What Sently Adds to Supabase Auth
- Your own domain: auth emails come from your brand, not a shared address.
- Delivery events: see when each email was sent, delivered or bounced.
- Automatic bounce handling: addresses that hard-bounce or mark you as spam are added to your blocked list, so later emails to them are skipped and your domain's reputation is protected.
- Room to grow: 3,000 free emails a month (up to 150 a day), and no daily limit on paid plans.