# Supabase Webhooks

## When to Use This Skill

* How do I receive Supabase Database Webhooks (INSERT / UPDATE / DELETE)?
* How do I verify a Supabase Auth Hook signature?
* Why is my Supabase `webhook-signature` verification failing?
* How do I secure a Supabase Database Webhook when there is no signature?
* How do I implement a `send_email` / `send_sms` / `custom_access_token` Auth Hook?
* What does the `v1,whsec_` secret prefix mean?

## Two Surfaces, Two Security Models

Supabase sends outbound HTTP from two different systems. They do not share a
security model — do not apply one's verification to the other.

|  | Database Webhooks | Auth Hooks (HTTP Hook) |
| --- | --- | --- |
| Source | Postgres trigger → `pg_net` | Supabase Auth (GoTrue) |
| Docs | [Database Webhooks](https://supabase.com/docs/guides/database/webhooks) | [Auth Hooks](https://supabase.com/docs/guides/auth/auth-hooks) |
| Events | `INSERT`, `UPDATE`, `DELETE` | 6 auth lifecycle hooks |
| Signature | None — no HMAC, no signing secret, no Supabase header | Standard Webhooks HMAC-SHA256 |
| Auth | Whatever headers you configure (e.g. `Authorization: Bearer …`) | `webhook-id` / `webhook-timestamp` / `webhook-signature` |
| Semantics | Fire-and-forget, async | Request/response — your JSON body changes auth behaviour |
| Retries | None documented | Up to 3 retries (2s backoff, 5s total budget) — requires a non-empty `retry-after` header |

Supabase documents no source-IP allowlist and no `user-agent` value for
either surface. Do not build either into your receiver.

## Verification (core)

### Auth Hooks — Standard Webhooks HMAC-SHA256

```javascript
const { Webhook } = require('standardwebhooks');

// Secret is issued as "v1,whsec_<base64>". Strip the "v1,whsec_" prefix; the
// remainder is STANDARD base64 that the library base64-DECODES to the raw HMAC
// key. Using the base64 string itself as the key rejects every real delivery.
const wh = new Webhook(process.env.SUPABASE_AUTH_HOOK_SECRET.replace('v1,whsec_', ''));

// Signs `{webhook-id}.{webhook-timestamp}.{raw_body}` and base64-compares in
// constant time against every space-delimited `v1,<sig>` entry, with a
// ±5-minute timestamp tolerance. Pass the RAW body — re-serialised JSON fails.
const payload = wh.verify(rawBody, {
  'webhook-id': headers['webhook-id'],
  'webhook-timestamp': headers['webhook-timestamp'],
  'webhook-signature': headers['webhook-signature'],
}); // throws WebhookVerificationError on failure

```

### Database Webhooks — developer-configured shared secret

There is no signature to verify. Authenticate with a header you set yourself
when creating the webhook, compared in constant time:

```javascript
const crypto = require('crypto');

function timingSafeEqualStr(a, b) {
  const x = Buffer.from(a || '', 'utf8');
  const y = Buffer.from(b || '', 'utf8');
  if (x.length !== y.length) return false; // length is not secret here
  return crypto.timingSafeEqual(x, y);
}

// Header value comes from the headers JSON you pass to supabase_functions.http_request
function authenticateDatabaseWebhook(headers, secret) {
  if (!secret) return false;
  const authorization = headers['authorization'] || '';
  const presented = authorization.toLowerCase().startsWith('bearer ')
    ? authorization.slice(7).trim()
    : headers['x-webhook-secret'] || '';
  return timingSafeEqualStr(presented, secret);
}

if (!authenticateDatabaseWebhook(req.headers, process.env.SUPABASE_WEBHOOK_SECRET)) {
  return res.status(401).json({ error: 'Unauthorized' });
}

```

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/supabase-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/supabase-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/supabase-webhooks/examples/fastapi/).

## Database Webhook Events

Only three, all fired after the row change:

| `type` | Fires when | `record` | `old_record` |
| --- | --- | --- | --- |
| `INSERT` | A row is inserted | new row | `null` |
| `UPDATE` | A row is updated | new row | previous row |
| `DELETE` | A row is deleted | `null` | deleted row |

`type` is UPPERCASE and is the discriminator. The full payload has exactly four
other top-level fields — there are no others:

```json
{ "type": "INSERT", "table": "<table name>", "schema": "<schema name>", "record": { }, "old_record": null }

```

`record` / `old_record` mirror the table's own columns, so their inner shape is
whatever your table defines.

Create one in the Dashboard (Integrations → Webhooks) or in SQL:

```sql
create trigger "my_webhook" after insert on "public"."my_table"
for each row execute function "supabase_functions"."http_request"(
  'https://example.com/webhooks/supabase',  -- url
  'POST',                                   -- method (POST or GET)
  '{"Content-Type":"application/json","Authorization":"Bearer YOUR_SHARED_SECRET"}',
  '{}',                                     -- params
  '1000'                                    -- timeout in ms
);

```

There is no delivery id header and no documented retry policy — `pg_net` is
fire-and-forget within `timeout_ms`. Delivery history lives in the database's
`net` schema. Idempotency is your receiver's job: dedupe on a primary key inside
`record` / `old_record`.

## Auth Hooks

Six hooks, config keys exactly as documented:

| Hook | Plans | Request payload | Your response |
| --- | --- | --- | --- |
| `before_user_created` | Free, Pro | `{ metadata: { uuid, time, name, ip_address }, user }` | `{}` to allow; `{ "error": { "http_code": 400, "message": "…" } }` to reject |
| `custom_access_token` | Free, Pro | `{ user_id, claims, authentication_method }` | `{ claims: { … } }` to write into the JWT |
| `send_sms` | Free, Pro | `{ user, sms: { otp } }` | `{}` — you send the SMS |
| `send_email` | Free, Pro | `{ user, email_data: { token, token_hash, redirect_to, email_action_type, site_url, token_new, token_hash_new, old_email, old_phone, provider, factor_type } }` | `{}` — you send the email |
| `mfa_verification_attempt` | Teams, Enterprise | `{ factor_id, user_id, valid }` | `{ decision: "continue" | "reject", message }` |
| `password_verification_attempt` | Teams, Enterprise | `{ user_id, valid }` | `{ decision: "continue" | "reject", message, should_logout_user }` |

Auth Hooks are request/response, not fire-and-forget. The auth flow blocks on
your reply and your JSON body changes what Supabase does. Errors are any status
`>= 400`; a `429` or `503` is retried up to three times with a two-second backoff
only if you also send a non-empty `retry-after` header (e.g.
`retry-after: true`), inside a 5-second total budget for the whole
invocation. Keep the handler fast and push slow work out of band. Always send
`Content-Type: application/json`; `204` is rejected by `custom_access_token`,
`mfa_verification_attempt` and `password_verification_attempt`, and `400` / `403`
are turned into a `500` returned to your application.

Auth Hooks can alternatively be configured as a Postgres function
(`pg-functions://postgres/<schema>/<fn>`), in which case no HTTP request leaves
the instance and none of the above applies. This skill covers the HTTP variant.

## Environment Variables

```bash
# Auth Hooks — the secret Supabase issues, including the "v1,whsec_" prefix
SUPABASE_AUTH_HOOK_SECRET=v1,whsec_UkxKUzBrOWt2c1hHTDF3YjNVSHhOZmw3Y0dyNXlKRHE=

# Database Webhooks — a shared secret YOU choose and put in the trigger's
# headers JSON. Supabase does not generate or sign anything here.
SUPABASE_WEBHOOK_SECRET=a-long-random-string-you-generate

```

Supabase's own config key for a hook secret is plural (e.g.
`SEND_SMS_HOOK_SECRETS`) because multiple pipe-delimited secrets are planned for
rotation. The `webhook-signature` header is already a space-delimited list of
`v1,<sig>` entries for exactly that reason — accept if any entry matches.

## Local Development

`--path` replaces the forwarded request path, so run one tunnel per surface:

```bash
# No install, no account required — creates a guest account on first run

# Database Webhooks (Express/Next.js on 3000; use 8000 for FastAPI)
npx hookdeck-cli listen 3000 supabase --path /webhooks/supabase

# Auth Hooks — a separate source, because --path replaces the request path
npx hookdeck-cli listen 3000 supabase-auth-hook --path /webhooks/supabase/auth-hook

```

Paste the first tunnel URL into the Dashboard (Integrations → Webhooks) and the
second as the Auth Hook URI (Authentication → Hooks). One tunnel for both would
route Auth Hooks into the Database Webhook handler and get a `401`.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/supabase-webhooks/references/overview.md) - Both surfaces, events, payload shapes
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/supabase-webhooks/references/setup.md) - Dashboard and SQL configuration, getting secrets
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/supabase-webhooks/references/verification.md) - Signature verification details and gotchas