← All guides
SupabaseJuly 12, 20266 min read

Testing Supabase Database Webhooks on localhost

Forward a Supabase database webhook to localhost with Relayers: tunnel Postgres triggers to your dev server, then filter and transform with JQ.

Supabase Database Webhooks are one of those features that look trivial until you try to develop against them. A row lands in orders, a Postgres trigger fires through pg_net, and an HTTP request goes out to some URL. Great — except that URL has to be publicly reachable. Your handler runs on localhost:3000, and there is no way to point a hosted Postgres instance at a port on your laptop.

So you end up in the usual loop: deploy a half-finished handler to staging, insert a test row, squint at logs, tweak, redeploy. Every iteration costs minutes. And because Supabase Database Webhooks don't HMAC-sign their payloads, you can't even confidently test your verification logic without shipping it first.

Relayers fixes the reachability problem. You get a public ingest URL, point the database webhook at it, and forward every delivery straight to your local server over a tunnel — original body and headers untouched.

What you'll build

Prerequisites

1Create an endpoint in Relayers

Open the desktop app, go to Endpoints, and create a new one. You'll get a public ingest URL that looks like this:

https://api.relayers.app/v1/webhooks/wep_1a2b3c4d5e

Copy it. Hit Send test if you want to confirm the endpoint is live before wiring up Supabase. This URL is the single stable target — everything downstream (filtering, transforming, tunneling) is configured in Relayers, so you never touch the Supabase side again once it's set.

2Create the Database Webhook in Supabase

In the Supabase dashboard, go to Database → Webhooks → Create a new hook:

  1. Name — something like orders_to_localhost.
  2. Table — select orders in the public schema.
  3. Events — check the operations you care about. For this walkthrough, INSERT, UPDATE, and DELETE.
  4. Type — choose HTTP Request.
  5. MethodPOST.
  6. URL — paste your Relayers ingest URL from step 1.
  7. HTTP Headers — add a custom auth header. Since Supabase Database Webhooks don't sign their payloads, this shared secret is how you'll verify authenticity later:
X-Webhook-Secret: super-secret-value-you-generate

Save the hook. From now on, every matching row change fires an HTTP POST to Relayers with a payload shaped like this:

{
  "type": "INSERT",
  "table": "orders",
  "record": { "id": 42, "amount": 1999, "status": "paid" },
  "old_record": null,
  "schema": "public"
}

3Forward events to localhost

Log in and start the tunnel from your terminal:

relayers login
relayers listen --forward localhost:3000

That's it. Insert a row in Supabase (or use the SQL editor: insert into orders (amount, status) values (1999, 'paid');) and the delivery lands on localhost:3000 within seconds. The desktop app's Events view shows each delivery, and the payload inspector lets you expand the exact JSON body and headers Supabase sent — including your X-Webhook-Secret.

If a delivery fails while your server is down, you can retry it from the Events view once you're back up.

4Route and filter with JQ

You rarely want every event hitting the same handler. Open Rules on your endpoint and add a JQ filter so only new orders get forwarded:

{ "type": "INSERT", "table": "orders" }

Rules are evaluated top to bottom, first match wins, and each rule has a destination — a tunnel to localhost:PORT or a public URL. You can reorder them by dragging. So you might route INSERT events to your local dev server and DELETE events to a separate audit URL, all from the same Supabase webhook.

5Transform the payload

The raw Supabase envelope is noisy for most handlers. In Transformations, reshape it to exactly what your endpoint expects. Using jq:

{ event: .type, table: .table, id: .record.id }

Given the earlier payload, your handler now receives:

{ "event": "INSERT", "table": "orders", "id": 42 }

Relayers supports jq, JSONata, JavaScript, and Go templates, and there's a test playground so you can paste a sample payload and see the output before you save. No redeploys to iterate on your mapping.

Verifying authenticity (no built-in signature)

This is the part worth calling out. Unlike Stripe or GitHub, Supabase Database Webhooks do not HMAC-sign their requests. There is no signature header to verify against a signing secret.

The practical answer is the custom header you added in step 2. Relayers forwards the original headers untouched, so your local handler sees X-Webhook-Secret exactly as Supabase sent it. Verify it with a constant-time comparison:

import crypto from "node:crypto";

function verify(req) {
  const got = req.headers["x-webhook-secret"] ?? "";
  const want = process.env.WEBHOOK_SECRET ?? "";
  const a = Buffer.from(got);
  const b = Buffer.from(want);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Because the header survives the tunnel intact, the verification code you test locally is the exact same code that runs in production.

Going to production

When you're ready to deploy, keep the Relayers endpoint and just change the destination on your rule from the tunnel to your public URL (for example https://api.yourapp.com/webhooks/supabase). The Supabase webhook config never changes — it still points at the same wep_<id> ingest URL. Filtering, transformation, and header pass-through all behave identically.

If you manage multiple environments, use Import/Export to move an endpoint's rules and transformations as a JSON bundle between projects.

Troubleshooting

Wrap-up

Supabase Database Webhooks are a clean way to turn Postgres row changes into HTTP calls, but the missing pieces — local reachability and signature verification — make them awkward to develop against. Relayers gives you a stable public ingest URL, a tunnel to localhost, JQ filtering, payload transformation, and untouched header pass-through so your verification logic works the same locally and in production.

Create a free endpoint at relayers.app and download the app to start forwarding your Supabase database webhooks to localhost in a couple of minutes.