Enabling a hook against a test URL
Create a URL above, then open Supabase Dashboard → Authentication → Hooks. Pick Send Email, choose HTTPS as the hook type, paste the URL, and save. Supabase generates a hook secret shaped like v1,whsec_… — copy it now.
Do this in a project you do not care about. While a Send Email hook is enabled, Supabase stops sending its own emails and delegates to your endpoint; a capture bin receives the payload and sends nothing, so confirmation links never arrive. That is exactly what you want for inspection and exactly what you do not want in production.
Then trigger it: sign up a user, or call supabase.auth.signInWithOtp({ email }). The delivery appears here within a second.
Locally, the equivalent lives in supabase/config.toml:
[auth.hook.send_email]
enabled = true
uri = "https://trywebhook.com/w/your-bin-id"
secrets = "env(SEND_EMAIL_HOOK_SECRET)"
Reading a Send Email payload
{
"user": { "id": "00000000-0000-4000-8000-000000000000", "email": "buyer@example.com" },
"email_data": {
"token": "000000",
"token_hash": "...",
"redirect_to": "https://example.com/welcome",
"email_action_type": "signup",
"site_url": "https://example.com",
"token_new": "",
"token_hash_new": ""
}
}
email_action_type is what you switch on — signup, magiclink, recovery, invite, email_change. It decides which template you render, and it is the only thing distinguishing an account confirmation from a password reset.
token is the six-digit OTP; token_hash is what goes into a confirmation link, as /auth/v1/verify?token={token_hash}&type={email_action_type}&redirect_to={redirect_to}. Include one, the other, or both, depending on whether your users type a code or click a link.
token_new and token_hash_new are empty except during an email change, where the old and new addresses each get their own token. An implementation that ignores them silently breaks email changes when secure email change is on.
Verifying the signature
Three headers, all part of what is signed:
webhook-id: msg_2fakeSupabaseHookId
webhook-timestamp: 1750000000
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
Build the signed content by joining the id, the timestamp, and the raw body with literal dots. Derive the key by stripping the prefix and base64-decoding:
const key = base64Decode(hookSecret.replace(/^v1,whsec_/, ''));
const signed = `${id}.${timestamp}.${rawBody}`;
HMAC-SHA256, base64-encode, compare against each v1, entry in the header. Then reject timestamps more than five minutes old.
Note the prefix. Clerk’s secrets start with whsec_; Supabase’s start with v1,whsec_. It is the same specification and the same code path — but a regular expression that only strips whsec_ leaves a stray v1, in the string, and every signature fails. In the Signature tab above, choose Svix / Standard Webhooks and paste the secret exactly as the dashboard shows it.
Database Webhooks, briefly
If what you are debugging fires on an INSERT rather than on a sign-up, you are looking at the other feature. Database Webhooks are triggers that call pg_net:
{
"type": "INSERT",
"table": "orders",
"schema": "public",
"record": { "id": 1, "total": 2900 },
"old_record": null
}
They are unsigned. There is no HMAC, no timestamp, and no retry — pg_net sends the request and forgets it, and a failure is visible only in the net._http_response table. Whatever authentication you want has to be a header you configure and check by hand, so use a long random bearer token and compare it in constant time.
They also fire inside the database transaction’s aftermath rather than in a user’s request, which means they are safe to point at a bin. Capture one here to see the record/old_record shape — it is the fastest way to learn which columns actually arrive.