Test Supabase Webhooks Locally
A Database Webhook fired and nothing happened — no error, no app log, because pg_net is fire-and-forget and the only trace lives in net._http_response for ~6 hours. Get a stable URL Supabase can reach, see the exact request the trigger sent, and replay it instead of mutating a row again to retry.
30-second setup
From a stuck handler to a live Supabase delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 54321. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL — something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3For a Database Webhook: Dashboard → Database → Webhooks → Create a new hook, point the URL at your LocalCan address with your path, e.g.
https://your-app-12.localcan.dev/webhook, pick the table and INSERT/UPDATE/DELETE events. These are unsigned, so add an HTTP header yourself (e.g.x-webhook-secret) and bump the request timeout above the 2000ms default. For an Auth Hook: Authentication → Hooks, choose the hook (Send Email, Custom Access Token…), set the same…localcan.devURL, and copy the generatedv1,whsec_…secret. - 4Database Webhook: there is no Resend button —
pg_netis fire-and-forget. Trigger the real row change in the SQL Editor, e.g.insert into your_table (col) values ('x');, then check delivery withselect * from net._http_response order by created desc limit 5;. Auth Hook: run the underlying flow — sign up or request a magic link for Send Email, request an OTP for Send SMS, log in for Custom Access Token. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body — expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Supabase webhooks
Both Supabase webhook systems POST from somewhere other than your laptop. A hosted project sends from Supabase’s servers; even the local CLI stack sends Database Webhooks from inside the Postgres container, where localhost means the container, not your machine. Either way localhost:54321 is unreachable from the sender.
With Database Webhooks the failure is invisible: pg_net returns a request id and never sees the HTTP result, so a DNS error or timeout produces no database error and nothing in your app. You need a public URL that forwards to your handler — and a stable one, because re-saving the hook URL and (for Auth Hooks) the v1,whsec_… secret on every restart gets old fast.
Just want to see what Supabase sends first? Peek at the raw payload on webhook.cool, then point the endpoint at LocalCan to forward it into localhost.
What makes the Supabase loop fast
One URL the trigger can always reach
Your …localcan.dev URL is saved in the project YAML and reused identically on every restart, so the Database Webhook (or Auth Hook) you registered keeps firing tomorrow without re-opening the Dashboard.
This matters more for Supabase than most providers: pg_net will not tell you the URL went stale — a dead endpoint just shows up later as a timed_out or error row in net._http_response, if you think to look. A fixed URL removes that whole failure mode.
See what pg_net actually sent
Database Webhooks are unsigned and fire-and-forget, so by default you cannot see the request at all — only the response Postgres stored. The inspector shows the full outgoing request: the {type,table,schema,record,old_record} body, and whether your custom x-webhook-secret header actually went out.
For Auth Hooks it shows the webhook-id, webhook-timestamp and webhook-signature headers verbatim — exactly the three values you need to recompute the HMAC when verification fails.
Replay instead of mutating a row again
There is no Resend button for a Database Webhook. To retry the bad delivery you would normally have to INSERT/UPDATE/DELETE another row and pollute your data. Hit Replay and LocalCan re-fires the exact captured request — same body, same headers — so you iterate on the handler against the real payload without touching the table.
For Auth Hooks, replay re-sends the same signed request so you can debug a webhook-signature mismatch deterministically.
Inspect & replay Supabase events
Common events
INSERTDatabase Webhook — record set, old_record nullUPDATEDatabase Webhook — record & old_record both setDELETEDatabase Webhook — record null, old_record setsend-emailAuth Hooksend-smsAuth Hookcustom-access-tokenAuth Hookbefore-user-createdAuth Hook
These are the deliveries developers actually wire up. For Database Webhooks the body shape changes per event — an INSERT carries record with old_record:null, a DELETE is the reverse — so code that blindly reads .record on a delete gets null. Open the captured request in Inspect Traffic and you can see exactly which shape arrived before you debug.
Trigger a row change (or the auth flow), watch it land with full headers and JSON body, fix the handler, and hit Replay to re-send the identical request — no new INSERT, no new sign-up.
Supabase-specific caveats: the inspector’s default max body size is 10 MB (larger payloads are truncated), and Auth Hooks follow the Standard Webhooks spec, which has a timestamp tolerance — if you sit on a captured request for a long time before replaying, your verifier may reject the webhook-timestamp as too old.
Verify the Supabase signature
- Header
webhook-signature (Auth Hooks only; Database Webhooks are unsigned)- Algorithm
- HMAC-SHA256, base64-encoded — header value is
v1,<base64> - What's signed
- There is no single answer because there are two systems. Database Webhooks sign nothing — the only authenticity check is whatever custom header (e.g.
x-webhook-secret) you added when creating the hook. Auth Hooks follow the Standard Webhooks spec: the signed string is{webhook-id}.{webhook-timestamp}.{raw body}— thewebhook-id, a literal dot, thewebhook-timestamp, another dot, then the exact request bytes — HMAC-SHA256 with the decoded secret, base64-encoded, and presented asv1,<base64>(space-delimited if multiple versions are present for key rotation). - Secret
- Database Webhooks: no secret exists by default — you invent one and pass it as a header, then verify it yourself. Auth Hooks: generate it in Authentication → Hooks; the Dashboard/
config.tomlvalue is the fullv1,whsec_<base64>string, but thestandardwebhookslibrary expects the secret with thev1,whsec_prefix STRIPPED. Note the config quirk: the env var name is singular (SEND_EMAIL_HOOK_SECRET), while theconfig.tomlfield is plural to allow comma-separated rotation:secrets="env(SEND_EMAIL_HOOK_SECRET)".
import express from 'express'
import { Webhook } from 'standardwebhooks'
// Auth Hook secret from the Dashboard looks like 'v1,whsec_<base64>'.
// The standardwebhooks library wants it WITHOUT the 'v1,whsec_' prefix.
const raw = process.env.SEND_EMAIL_HOOK_SECRET // 'v1,whsec_...'
const secret = raw.replace('v1,whsec_', '')
const wh = new Webhook(secret)
const app = express()
// Standard Webhooks signs the RAW body — mount express.raw, not express.json.
app.post('/auth-hook', express.raw({ type: 'application/json' }), (req, res) => {
const headers = {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
}
try {
// verify() rebuilds '{id}.{timestamp}.{body}', HMAC-SHA256, base64,
// and checks the timestamp tolerance. Throws on mismatch or stale ts.
const payload = wh.verify(req.body, headers)
res.json({ ok: true })
} catch (err) {
res.status(401).send('Auth Hook verify failed: ' + err.message)
}
})
// Database Webhooks are UNSIGNED — verify your own custom header instead.
app.post('/db-webhook', express.json(), (req, res) => {
if (req.headers['x-webhook-secret'] !== process.env.DB_WEBHOOK_SECRET) {
return res.status(401).send('bad secret')
}
const { type, table, record, old_record } = req.body
// DELETE has record:null; INSERT has old_record:null — guard before reading.
res.json({ received: type })
})LocalCan does not verify either scheme for you — your code does — but it makes a failure debuggable instead of silent.
For a Database Webhook that is the whole game: pg_net swallows the result, so the inspector is your only live view of the outgoing request, including whether your x-webhook-secret header actually went out. For an Auth Hook, it shows the real webhook-id, webhook-timestamp and webhook-signature next to the untouched body, so you can recompute {id}.{timestamp}.{body} by hand and tell a stripped-prefix mistake from a parsed-body mistake from a stale timestamp — then Replay the same request to confirm the fix.
LocalCan vs Supabase CLI
Supabase has no first-party tunnel that forwards a webhook to your machine — the documented local path is the Supabase CLI (supabase start), which runs Postgres + Auth + Edge Functions in Docker. The honest split: the CLI is the right tool for exercising the full stack locally; LocalCan is what you reach for when a hosted project (or a third party) must reach a handler on your laptop, when you want one stable URL and a GUI inspector, or when pg_net failures need to stop being invisible.
| LocalCan | Supabase CLI | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | Local Docker stack; no public URL |
| Reach a host server from the DB | Public URL works from container or hosted project | Needs host.docker.internal, not localhost |
| Run the full stack locally | Not its job — forwards to your handler | supabase start: Postgres + Auth + Functions |
| See the outgoing pg_net request | GUI inspector shows full request, on by default | Only net._http_response (response, ~6h TTL) |
| Replay a delivery | One-click Replay of the exact request | No replay — re-trigger the row change |
| Trigger test events | Run the SQL / auth flow | Real INSERT / auth flow against local stack |
| Works across providers | One URL for Supabase, Stripe, GitHub… | Supabase only |
| Share with a teammate | Shareable URL + basic auth | Local only |
Supabase webhook FAQ
Stop guessing why the webhook 401'd. Get a stable Supabase endpoint, read every delivery, and replay it until your handler is right.
Free trial. No credit card required.