The answer. A Database Webhook is nothing more than a row-level after trigger that calls supabase_functions.http_request with a URL, a method, headers, params and a timeout; pg_net queues the HTTP call and the transaction commits without waiting for it. The payload is fixed: type, table, schema, record and old_record. Because the call is asynchronous and not retried by the database, the receiving side has to be idempotent (deduplicate on the row id and operation) and has to be told where to find you per environment: hard-coding a production URL into a trigger that also runs on branches and local stacks is how staging rows land in production queues. Store the base URL in Vault and let a wrapper trigger function prefix relative paths with it.
The pattern.
create trigger tickets_created after insert on public.tickets for each row
execute function supabase_functions.http_request(
'https://api.example.com/hooks/ticket-created', 'POST',
'{"Content-Type":"application/json","x-hook-secret":"replace-with-vault-lookup"}', '{}', '5000');
{ "type": "INSERT", "table": "tickets", "schema": "public",
"record": { "id": 42, "tenant": "packt", "title": "…" }, "old_record": null }
local stack: the database container's localhost is itself → use http://host.docker.internal:3000 (your app)
or http://kong:8000/functions/v1/<fn> (an Edge Function on the same stack)
Watch out.
- Calls are logged in
supabase_functions.hooks(andnet._http_responsefor pg_net); that is the only delivery evidence you get, so check it when a hook "did not fire". - The trigger runs inside the user's transaction as their role; a helper it calls to read Vault must be
security definer, and a hook that raises blocks the insert. - Sign the call (a secret header) and verify it on the receiver; the endpoint is otherwise reachable by anyone who guesses the path.
Related: edge-function-per-new-row-through-a-webhook · vault-keeps-third-party-secrets-inside-postgres · queue-work-returns-202-client-polls-a-status-row