NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Supabase delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 54321. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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.dev URL, and copy the generated v1,whsec_… secret.
  4. 4Database Webhook: there is no Resend button — pg_net is fire-and-forget. Trigger the real row change in the SQL Editor, e.g. insert into your_table (col) values ('x');, then check delivery with select * 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.
  5. 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 null
  • UPDATEDatabase Webhook — record & old_record both set
  • DELETEDatabase Webhook — record null, old_record set
  • send-emailAuth Hook
  • send-smsAuth Hook
  • custom-access-tokenAuth Hook
  • before-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} — the webhook-id, a literal dot, the webhook-timestamp, another dot, then the exact request bytes — HMAC-SHA256 with the decoded secret, base64-encoded, and presented as v1,<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.toml value is the full v1,whsec_<base64> string, but the standardwebhooks library expects the secret with the v1,whsec_ prefix STRIPPED. Note the config quirk: the env var name is singular (SEND_EMAIL_HOOK_SECRET), while the config.toml field is plural to allow comma-separated rotation: secrets="env(SEND_EMAIL_HOOK_SECRET)".
Node.js
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.

LocalCanSupabase CLI
Stable public URLPersistent …localcan.dev, survives restartsLocal Docker stack; no public URL
Reach a host server from the DBPublic URL works from container or hosted projectNeeds host.docker.internal, not localhost
Run the full stack locallyNot its job — forwards to your handlersupabase start: Postgres + Auth + Functions
See the outgoing pg_net requestGUI inspector shows full request, on by defaultOnly net._http_response (response, ~6h TTL)
Replay a deliveryOne-click Replay of the exact requestNo replay — re-trigger the row change
Trigger test eventsRun the SQL / auth flowReal INSERT / auth flow against local stack
Works across providersOne URL for Supabase, Stripe, GitHub…Supabase only
Share with a teammateShareable URL + basic authLocal 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.