Gareth Wilson Gareth Wilson

Guide to Supabase Webhooks: Features and Best Practices

Published


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

FeatureDatabase WebhooksAuth Hooks (HTTP)
SourcePostgres trigger → pg_netSupabase Auth
ConfigurationDashboard (Integrations → Webhooks) or SQLDashboard (Authentication → Hooks) or config.toml
EventsINSERT, UPDATE, DELETE6 auth lifecycle hooks
SignatureNoneStandard Webhooks HMAC-SHA256
HeadersOnly the ones you configurewebhook-id, webhook-timestamp, webhook-signature
SecretYours to inventIssued by Supabase as v1,whsec_<base64>
SemanticsFire-and-forgetRequest/response; your body changes auth behaviour
RetriesNone documentedUp to 3, at 2s backoff, inside a 5s total budget
Delivery idNonewebhook-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:

typeFires whenrecordold_record
INSERTA row is insertednew rownull
UPDATEA row is updatednew rowprevious row
DELETEA row is deletednulldeleted row

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

{ "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:

HookPlansFires whenYour response
before_user_createdFree, ProImmediately before a user row is created{} to allow, { "error": { ... } } with a 4xx to reject
custom_access_tokenFree, ProA JWT is about to be issued{ "claims": { ... } }
send_emailFree, ProAn email needs delivering{}, after you've sent it
send_smsFree, ProAn SMS needs delivering{}, after you've sent it
mfa_verification_attemptTeams, EnterpriseA user verifies an MFA factor{ "decision": "continue" | "reject", "message": "..." }
password_verification_attemptTeams, EnterpriseA user signs in with a passwordas above, plus should_logout_user

See Supabase webhook payloads in action. Inspect and replay sample payloads in the Hookdeck Console — 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:

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

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:

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

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.

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 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 for free and handle Supabase webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.