# Webhooks

> Standard Webhooks verification, typed Supabase Auth hooks and database webhook payloads.

Source: https://bettersupabase.com/docs/standards/webhooks

## Supabase Auth hooks [#supabase-auth-hooks]

Supabase signs HTTP Auth hooks with [Standard Webhooks](https://www.standardwebhooks.com).
`authHook` verifies the signature, types the payload for the hook and
serializes the answer:

```ts title="app/api/hooks/access-token/route.ts"
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](/docs/plugins/tenant) and the generated storage and realtime
policies read.

## Any Standard Webhook [#any-standard-webhook]

```ts
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_* code
```

It 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](/docs/blocks/webhooks-out).

## Stripe webhooks [#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:

```ts
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]

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`:

```ts
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.