Webhooks
Standard Webhooks verification, typed Supabase Auth hooks and database webhook payloads.
Supabase Auth hooks
Supabase signs HTTP Auth hooks with Standard Webhooks.
authHook verifies the signature, types the payload for the hook and
serializes the answer:
import { authHook, hookError } from "better-supabase/blocks/webhooks";
export const POST = authHook(
"custom_access_token",
process.env.AUTH_HOOK_SECRET!,
async ({ user_id, claims }) => {
const membership = await admin.memberships
.findFirst({ where: { userId: user_id } })
.orThrow();
if (!membership) return hookError(403, "No organization");
return { claims: { ...claims, tenant_id: membership.organizationId } };
},
);The hooks are custom_access_token, send_email, send_sms,
mfa_verification_attempt, password_verification_attempt and
before_user_created. Pass the secret as the dashboard shows it
(v1,whsec_…). Invalid signatures get a 401 before your handler runs.
The tenant_id claim this example adds is the one the
tenant plugin and the generated storage and realtime
policies read.
Any Standard Webhook
import { verifyWebhook, signWebhook } from "better-supabase/blocks/webhooks";
const result = await verifyWebhook<MyPayload>(request, [
currentSecret,
previousSecret,
]);
if (!result.ok) return problemResponse(result.error); // 401 with a WEBHOOK_* codeIt checks webhook-id, webhook-timestamp (5 minutes of tolerance by
default) and every v1 signature against each secret, in constant time. Pass
several secrets while rotating. Senders built on Svix use the same scheme
under svix-id, svix-timestamp and svix-signature; when a request has no
webhook-id, the svix- set is read instead, so one verifier takes both. The verified result carries timestamp as a
Temporal.Instant. signWebhook(secret, { id, body }) gives the headers for
sending, and is useful in tests; pass timestamp (a Temporal.Instant) to fix
the signing time.
To send webhooks to your customers' endpoints, with subscriptions, retries and a delivery log, use the outgoing webhooks block.
Stripe webhooks
Stripe signs with its own Stripe-Signature header (t=…,v1=…).
verifyStripeWebhook(request, secret) checks it with WebCrypto, so it runs
without the stripe package and on every runtime, and returns the parsed
event. Pass an array while rolling the endpoint secret. For the webhook
inbox, stripeInboxVerify(secret) is the verify option:
import { createWebhookInbox } from "better-supabase/blocks/jobs";
import { stripeInboxVerify } from "better-supabase/blocks/webhooks";
const inbox = createWebhookInbox(postgres.admin, {
source: "stripe",
verify: stripeInboxVerify(process.env.STRIPE_WEBHOOK_SECRET!),
});signStripeWebhook(secret, body) builds the header for tests.
Database webhooks
Database webhooks (pg_net) aren't signed. Add a secret header when you
create them and check it with verifySharedSecret(request, secret). Then
read the payload with databaseChange:
const change = databaseChange(
betterSupabase,
"customers",
await request.json(),
);
if (change?.type === "INSERT") await welcome(change.record!.email);record and oldRecord are app-cased and typed. databaseChange returns
null for other tables. Pair it with the SQL modules' webhook inbox to handle
each delivery once.
Last updated on