Frameworks and agents · Updated 2026-09-22
Send email from Supabase
Supabase's Send Email Hook, so sign-up, sign-in-link and password-recovery mail leaves through your own verified domain.
To send Supabase Auth email through your own domain, enable the Send Email Hook and point it at an endpoint you host: Supabase posts every sign-up, sign-in-link, invite and password-recovery email to it, and the handler below verifies the signature, builds the link from the token hash, and sends it through AgentiSend. The handler is executed against a real API on every build of this site's repository.
Install
pnpm add agentisendEnvironment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
SEND_EMAIL_HOOK_SECRET | yes | The secret Supabase shows when you enable the hook (v1,whsec_...). |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The handler
/**
* Supabase "Send Email Hook" — you own the auth email instead of Supabase's
* built-in SMTP.
*
* Supabase POSTs the sign-up / magic-link / recovery payload to an HTTPS
* endpoint you host, signed with Standard Webhooks headers. This handler
* verifies that signature, renders the link, and sends it through AgentiSend.
* It is a plain `Request` -> `Response` function, so it drops into a Next.js
* route handler, a Supabase Edge Function, Hono, or anything else that speaks
* fetch.
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
import { AgentiSend, AgentiSendError } from 'agentisend';
const agentisend = new AgentiSend();
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
/** The payload Supabase sends. Only the fields this handler reads are typed. */
export interface SendEmailHookPayload {
user: { id: string; email: string };
email_data: {
token: string;
token_hash: string;
redirect_to: string;
email_action_type: 'signup' | 'recovery' | 'invite' | 'magiclink' | 'email_change';
site_url: string;
};
}
const SUBJECTS: Record<SendEmailHookPayload['email_data']['email_action_type'], 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',
};
/**
* Standard Webhooks verification, as Supabase signs it: the signed content is
* `id.timestamp.body`, HMAC-SHA256 under the base64 secret, and the header
* carries one or more space-separated `v1,<signature>` pairs.
*/
export function verifySignature(
secret: string,
headers: { id: string; timestamp: string; signature: string },
body: string,
): boolean {
const key = Buffer.from(secret.replace(/^v1,whsec_|^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key)
.update(`${headers.id}.${headers.timestamp}.${body}`)
.digest('base64');
return headers.signature.split(' ').some((candidate) => {
const value = candidate.startsWith('v1,') ? candidate.slice(3) : candidate;
const a = Buffer.from(value);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}
export async function POST(request: Request): Promise<Response> {
const secret = process.env.SEND_EMAIL_HOOK_SECRET;
if (!secret) throw new Error('Set SEND_EMAIL_HOOK_SECRET to the secret Supabase generated.');
const body = await request.text();
const ok = verifySignature(secret, {
id: request.headers.get('webhook-id') ?? '',
timestamp: request.headers.get('webhook-timestamp') ?? '',
signature: request.headers.get('webhook-signature') ?? '',
}, body);
if (!ok) {
return Response.json({ error: { http_code: 401, message: 'Bad signature' } }, { status: 401 });
}
const { user, email_data } = JSON.parse(body) as SendEmailHookPayload;
const link =
`${email_data.site_url}/auth/v1/verify` +
`?token=${email_data.token_hash}` +
`&type=${email_data.email_action_type}` +
`&redirect_to=${encodeURIComponent(email_data.redirect_to)}`;
try {
await agentisend.emails.send(
{
from: mailFrom(),
to: user.email,
subject: SUBJECTS[email_data.email_action_type],
html: `<p><a href="${link}">Continue</a></p><p>Or enter this code: ${email_data.token}</p>`,
},
// One token, one email. Supabase retries the hook on a timeout; this key
// makes the retry replay rather than send a second copy.
{ idempotencyKey: `supabase-auth/${email_data.token_hash}` },
);
} catch (err) {
if (err instanceof AgentiSendError) {
// Supabase surfaces this message to the end user's sign-up attempt, so
// hand back the fix rather than a stack trace.
return Response.json(
{ error: { http_code: err.status, message: err.fix } },
{ status: err.status },
);
}
throw err;
}
return Response.json({});
}Wire it up
- Host the handler at a public URL: a Next.js route handler, a Supabase Edge Function, or any fetch-compatible runtime.
- In the Supabase dashboard, open Authentication, then Hooks, then Send Email Hook, choose HTTPS and paste the URL.
- Copy the generated secret into the hook-secret variable.
Sign up a test user. The message id is in your AgentiSend log, and GET /emails/{id}/explain says what happened to it.
Why the idempotency key is the token hash
Supabase retries the hook when your endpoint times out. The token hash is unique per email, so a retry replays the first send rather than delivering a second copy of the same link.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The error catalogue lists every code, and domain_not_verified names the call that repairs it.