# Guide to Supabase Webhooks: Features and Best Practices

Supabase sends outbound HTTP to your endpoint from two places: a Postgres trigger when a row changes, and Supabase Auth at defined points in the login and signup flow. Both are called webhooks. They share almost nothing else.

This guide covers both surfaces, how to secure each one, and the best practices for running them in production.

## What are Supabase webhooks?

Database Webhooks wrap a Postgres trigger around the [`pg_net`](https://github.com/supabase/pg_net) extension. When a row is inserted, updated or deleted in a table you're watching, Postgres fires an asynchronous HTTP request to your URL. Supabase does not sign it. The only authentication is whatever headers you put in the trigger definition yourself.

Auth Hooks are the opposite in nearly every respect. Supabase Auth calls your endpoint before it creates a user, before it issues a JWT, or when it needs an email or SMS sent, and it waits for your reply. Your JSON response changes what happens next. These requests are signed with Standard Webhooks HMAC-SHA256.

Neither should be confused with Supabase Realtime or Supabase Queues, which are websocket and in-database mechanisms rather than outbound HTTP.

## Supabase webhook features

| Feature | Database Webhooks | Auth Hooks (HTTP) |
| --- | --- | --- |
| Source | Postgres trigger → `pg_net` | Supabase Auth |
| Configuration | Dashboard (Integrations → Webhooks) or SQL | Dashboard (Authentication → Hooks) or `config.toml` |
| Events | `INSERT`, `UPDATE`, `DELETE` | 6 auth lifecycle hooks |
| Signature | None | Standard Webhooks HMAC-SHA256 |
| Headers | Only the ones you configure | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Secret | Yours to invent | Issued by Supabase as `v1,whsec_<base64>` |
| Semantics | Fire-and-forget | Request/response; your body changes auth behaviour |
| Retries | None documented | Up to 3, at 2s backoff, inside a 5s total budget |
| Delivery id | None | `webhook-id` |

Supabase documents no source-IP allowlist and no `user-agent` value for either surface, so don't build either into your receiver.

## Common events

Database Webhooks have exactly three events, 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 |

The `type` field is UPPERCASE and is the discriminator. The payload has exactly four other top-level fields:

```json
{ "type": "UPDATE", "table": "orders", "schema": "public", "record": { "id": 42, "status": "shipped" }, "old_record": { "id": 42, "status": "paid" } }

```

`record` and `old_record` mirror the watched table's own columns, so their inner shape is defined by your schema rather than by Supabase. Nothing inside them is a documented API surface, and a migration can change them.

Auth Hooks have six, each with its own payload and its own expected response:

| Hook | Plans | Fires when | Your response |
| --- | --- | --- | --- |
| `before_user_created` | Free, Pro | Immediately before a user row is created | `{}` to allow, `{ "error": { ... } }` with a 4xx to reject |
| `custom_access_token` | Free, Pro | A JWT is about to be issued | `{ "claims": { ... } }` |
| `send_email` | Free, Pro | An email needs delivering | `{}`, after you've sent it |
| `send_sms` | Free, Pro | An SMS needs delivering | `{}`, after you've sent it |
| `mfa_verification_attempt` | Teams, Enterprise | A user verifies an MFA factor | `{ "decision": "continue" | "reject", "message": "..." }` |
| `password_verification_attempt` | Teams, Enterprise | A user signs in with a password | as above, plus `should_logout_user` |

> See Supabase webhook payloads in action. Inspect and replay sample payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up Supabase webhooks

A Database Webhook is a Postgres trigger, so you can create it in the Dashboard under Integrations → Webhooks, or write it directly in a migration:

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

```

The secret in that headers JSON is the whole of your authentication, and it lives in the trigger definition, which means it lands in schema dumps. Read it from [Supabase Vault](https://supabase.com/docs/guides/database/vault) rather than inlining it if that matters to you.

For an Auth Hook, go to Authentication → Hooks, pick the hook, choose HTTPS rather than Postgres as the type, and enter your URI. Supabase generates a secret and shows it once. Copy the whole thing, `v1,whsec_` prefix included.

Choosing Postgres instead points the hook at a database function (`pg-functions://postgres/<schema>/<fn>`), and no HTTP request leaves the instance at all.

## Securing Supabase webhooks

### Auth Hooks: Standard Webhooks HMAC-SHA256

Supabase signs `{webhook-id}.{webhook-timestamp}.{raw_body}` with HMAC-SHA256 and base64-encodes the result. Use the reference library rather than writing it yourself:

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

// The secret arrives as "v1,whsec_<base64>". Strip the prefix; the library
// base64-DECODES what remains into the raw HMAC key.
const wh = new Webhook(process.env.SUPABASE_AUTH_HOOK_SECRET.replace('v1,whsec_', ''));

// Pass the RAW body. Re-serialised JSON changes whitespace and key order.
const payload = wh.verify(rawBody, {
  'webhook-id': headers['webhook-id'],
  'webhook-timestamp': headers['webhook-timestamp'],
  'webhook-signature': headers['webhook-signature'],
}); // throws on failure

```

`webhook-signature` is a space-delimited list of `v1,<sig>` entries so a secret can be rotated without downtime, and the timestamp tolerance is five minutes in either direction. The library handles both.

### Database Webhooks: a secret you configure

There is no signature, so authenticate with the header you set on the trigger, compared in constant time:

```python
import hmac
import os

SECRET = os.environ.get("SUPABASE_WEBHOOK_SECRET", "")

def authenticate(headers) -> bool:
    if not SECRET:
        return False  # fail closed: "unset" must never mean "allow anything"
    presented = headers.get("authorization", "").removeprefix("Bearer ").strip()
    return hmac.compare_digest(presented, SECRET)

```

`hmac.compare_digest` here is a constant-time string comparison, not an HMAC. Nothing is being hashed, because there is nothing to hash.

> Make Supabase webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) authenticates both surfaces, deduplicates, retries, and durably queues every event.

## Supabase webhook limitations and pain points

### Database Webhooks are never retried

The Problem: `pg_net` dispatches the request and forgets it. A 500 from your handler, a timeout, or a deploy that takes your endpoint down for thirty seconds loses those events permanently. There's no queue holding them and no backoff schedule.

Why It Happens: The trigger is fire-and-forget within the `timeout_ms` you set, and Supabase documents no retry policy for this surface.

Workarounds:

* Query `select * from net._http_response order by created desc;` to see what happened, then replay by hand.
* Put something durable in front of the endpoint so a failed delivery can be retried.

How Hookdeck Can Help: Hookdeck accepts and persists the event before your service sees it, then retries on your schedule and lets you replay anything.

### The `v1,whsec_` secret rejects every delivery if you mishandle it

The Problem: Auth Hook verification fails on every real request while the test suite passes, because the test signer makes the same mistake as the verifier.

Why It Happens: The secret is issued as `v1,whsec_<base64>`. The base64 part is the encoded key and must be decoded to raw bytes. The npm and PyPI libraries strip `whsec_` for you but not the `v1,`, which is why Supabase's own example calls `.replace('v1,whsec_', '')` first.

Workarounds:

* Strip `v1,whsec_`, hand the remainder to the library, and let it decode.
* Sign a known payload with the library and compare, rather than trusting a self-built test signer.

How Hookdeck Can Help: Hookdeck verifies Standard Webhooks signatures at the edge, so the secret format is handled in one place.

### Auth Hooks block the auth flow

The Problem: A slow `send_email` handler doesn't just delay an email. It delays the signup. The whole invocation, retries included, has a five-second budget, and going over it breaks the user-facing flow.

Why It Happens: Auth Hooks are request/response rather than fire-and-forget. Supabase Auth waits for your reply and acts on your JSON body.

Workarounds:

* Do the minimum synchronously and push everything else out of band.
* Send `Content-Type: application/json` on every response, errors included. `204` isn't accepted by `custom_access_token`, `mfa_verification_attempt` or `password_verification_attempt`, and a `400` or `403` is turned into a `500` returned to your application.

How Hookdeck Can Help: Hookdeck's request logs show every attempt and its latency, so you can find the slow hook rather than guessing at it.

### A `503` isn't retried unless you ask for it

The Problem: A handler returns `503` when its email provider is down, expecting Supabase to retry. It doesn't, and the message is lost.

Why It Happens: A `429` or `503` gets up to three retries at a two-second backoff, but only if the response also carries a non-empty `retry-after` header. Supabase checks only that the header has a value.

Workarounds:

* Set `retry-after` on every retryable error response. `retry-after: true` is enough.

How Hookdeck Can Help: Hookdeck's retry logic doesn't depend on the response header the provider happens to look for.

## Best practices

### Handle the two surfaces on two routes

Give Database Webhooks and Auth Hooks separate endpoints with separate secrets. Sharing a route means one handler branching on which authentication to apply, and the failure mode is a rejected delivery you'll misread as a signature bug.

### Fail closed on the Database Webhook secret

Treat an unset `SUPABASE_WEBHOOK_SECRET` as "reject", not "allow anything". An unsigned endpoint that skips authentication when misconfigured is an open endpoint.

### Dedupe on a primary key

Database Webhooks carry no delivery id and no idempotency header, so deduplicate on a primary key inside `record`, or `old_record` for `DELETE`. Auth Hooks give you `webhook-id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep Auth Hook handlers fast, and everything else asynchronous

Auth Hooks have five seconds and the user is waiting. Database Webhooks have your trigger's `timeout_ms` and no retry, so acknowledge fast and queue the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Don't treat `record` as a stable contract

Its shape is your table's shape. A column rename ships a breaking payload change to your own handler, so version the receiver alongside the migration.

## Conclusion

Supabase's two webhook surfaces need two different handlers. Database Webhooks are unsigned, unretried Postgres triggers, so authenticate them with a secret you configure, fail closed when it's missing, and deduplicate on a primary key. Auth Hooks are signed with Standard Webhooks HMAC-SHA256, block the auth flow, and give you five seconds, so strip the `v1,whsec_` prefix, verify against the raw body, and keep the handler fast.

[Hookdeck Event Gateway](https://hookdeck.com) authenticates both surfaces, deduplicates, retries, and durably queues every event at the edge, so your app only ever processes verified, unique events, including the Database Webhooks Supabase would otherwise drop.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Supabase webhooks reliably in minutes.