Implement Supabase webhook signature validation and event handling. Use when setting up webhook endpoints, implementing signature verification, or handling Supabase event notifications...
Supabase offers four complementary event mechanisms: Database Webhooks (trigger-based HTTP calls via pg_net), supabase_functions.http_request() (call Edge Functions from triggers), Postgres LISTEN/NOTIFY (lightweight pub/sub), and Realtime postgres_changes (client-side event subscriptions). This skill covers all four patterns with production-ready code including signature verification, idempotency, and retry handling.
supabase CLI installedpg_net extension enabled: Dashboard > Database > Extensions > search "pg_net" > Enable@supabase/supabase-js v2+ installed for client-side patternsBoth directions of a webhook are authenticated:
Authorization: Bearer <service_role_key> header. Store the key in a Postgres setting (app.settings.service_role_key) or Supabase Vault — never inline it in a committed migration.WEBHOOK_SECRET (read from Deno.env) using a constant-time comparison, and reject mismatches with 401. See signature-verification.md.Pick the mechanism that fits the consumer: pg_net triggers for server-side HTTP fan-out, Edge Function receivers for signed processing, LISTEN/NOTIFY for in-database pub/sub, and Realtime for client UI. Write each SQL trigger to a supabase/migrations/ file and each handler to supabase/functions/<name>/index.ts, then apply and deploy with the supabase CLI.
pg_net and Trigger FunctionsEnable pg_net, then write a trigger function that POSTs the changed row to an Edge Function. Attach it AFTER INSERT/UPDATE/DELETE. Full trigger set (conditional status-change trigger, the supabase_functions.http_request() built-in helper, and net._http_response inspection queries) is in database-webhooks.md.
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
CREATE OR REPLACE FUNCTION public.notify_order_created()
RETURNS trigger AS $$
BEGIN
PERFORM net.http_post(
url := 'https://<project-ref>.supabase.co/functions/v1/on-order-created',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := jsonb_build_object('type', TG_OP, 'record', row_to_json(NEW)::jsonb)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_order_created
AFTER INSERT ON public.orders
FOR EACH ROW EXECUTE FUNCTION public.notify_order_created();
Write an Edge Function that reads the raw body, verifies the HMAC signature, parses the typed payload, and routes by event type. Guard against duplicate delivery with a processed_events idempotency table. The complete receiver, the idempotent-handler variant, and the idempotency table DDL are in edge-function-receivers.md.
// supabase/functions/on-order-created/index.ts
serve(async (req) => {
const rawBody = await req.text();
const secret = Deno.env.get("WEBHOOK_SECRET");
if (secret) {
const sig = req.headers.get("x-webhook-signature") ?? "";
if (!(await verifySignature(rawBody, sig, secret)))
return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 });
}
const payload = JSON.parse(rawBody); // { type, table, record, old_record }
// route by payload.type: INSERT | UPDATE | DELETE
return new Response(JSON.stringify({ received: true }));
});
Use pg_notify from a trigger for lightweight, non-persistent pub/sub consumed by a backend LISTEN; use Realtime postgres_changes for client-side UI subscriptions (keep NOTIFY payloads to IDs — they truncate past 8000 bytes). The backend listener, the full Realtime subscription with event routing, and the combined event-driven architecture diagram are in listen-notify-realtime.md.
const channel = supabase
.channel("orders-events")
.on("postgres_changes",
{ event: "*", schema: "public", table: "orders" },
(payload) => console.log(payload.eventType, payload.new))
.subscribe();
These patterns produce:
pg_net on row changes| Error | Cause | Fix |
|---|---|---|
pg_net returns 404 |
Edge Function not deployed or wrong URL | Run supabase functions deploy <name> and verify the URL matches |
| Webhook not firing | Trigger not attached or table not in publication | Check SELECT * FROM pg_trigger WHERE tgrelid = 'orders'::regclass; |
| Duplicate events processed | No idempotency layer | Add processed_events table with unique event_id constraint |
| Realtime not receiving | Table not added to Realtime publication | Dashboard > Database > Replication > enable the table |
net._http_response shows 401 |
Invalid or missing auth header | Verify service_role_key is set in app.settings or vault |
| NOTIFY payload truncated | Payload exceeds 8000 bytes | Send only IDs in NOTIFY, fetch full record in the listener |
| Auth hook errors | Function raises exception | Check Dashboard > Logs > Auth; ensure function returns valid JSONB |
| Trigger silently fails | SECURITY DEFINER without search_path |
Add SET search_path = public, extensions; to function |
pg_net trigger set: conditional status-change trigger, the supabase_functions.http_request() helper, and net._http_response inspection queries.LISTEN client, full Realtime subscription, and the combined event-driven architecture diagram.channel().on() APIFor performance optimization of triggers and queries, see supabase-performance-tuning. For production hardening including RLS policies on webhook-accessed tables, see supabase-security-basics.