Database webhooks and HTTP requests with pg_net
Send HTTP requests from Postgres on SupaHost: database webhooks that post row changes to a URL or Edge Function, direct pg_net calls, reading responses and debugging.
Sometimes a row change should tell another system: a new booking should ping your team chat, a paid invoice should reach your accounting tool, a new upload should start an Edge Function. Postgres can do this itself with pg_net, an extension that makes HTTP requests in the background. Database webhooks are a thin layer on top: a trigger that posts the changed row as JSON to a URL.
Both are ready in every SupaHost project. pg_net and the supabase_functions schema are installed when the project is created, so there is nothing to enable.
How it behaves
- Asynchronous. A request is queued and sent by a background worker after your transaction commits. The insert or update doesn't wait for the remote server, and a rolled-back transaction sends nothing.
- Fire and forget. pg_net doesn't retry. If the remote server is down or slow, the request fails and the result is recorded, but nothing tries again.
- Responses are kept for a while in
net._http_response, so you can check what happened. - On SupaHost, requests go out within about a second. Every project database shares one Postgres server, and pg_net's worker runs in the server's main database. SupaHost relays each project's committed requests to it and writes the response back into your project's
net._http_responseunder the same request ID. In a test, a request took about 0.65 seconds fromnet.http_getto a stored response.
Create a database webhook
A database webhook is a trigger that calls supabase_functions.http_request with up to five arguments: URL, method (POST or GET), headers as JSON, query parameters as JSON, and a timeout in milliseconds.
create trigger bookings_notify
after insert or update on public.bookings
for each row
execute function supabase_functions.http_request(
'https://hooks.example.com/bookings',
'POST',
'{"Content-Type": "application/json", "X-Webhook-Token": "change-me"}',
'{}',
'3000'
);
For POST, the body describes the change:
{
"type": "INSERT",
"table": "bookings",
"schema": "public",
"record": { "id": 41, "room": "Blue", "starts_at": "2026-11-12T09:00:00+00:00", "...": "..." },
"old_record": null
}
type is INSERT, UPDATE or DELETE. old_record holds the previous row on updates and deletes. If you leave out the timeout it is 1000 ms, which is tight for many APIs.
Header values are stored in the trigger's definition, which anyone with access to the database schema can read. Use a token made only for this webhook, not a key you use elsewhere.
Studio also has a Database Webhooks screen that builds the same kind of trigger for you.
Call an Edge Function on a change
Point the webhook at your function's URL. With "Require JWT" on (the default), the function accepts any valid JWT for the project, including the anon key, so sending the anon key is enough to get past that check:
create trigger invoices_paid_hook
after update of status on public.invoices
for each row
when (new.status = 'paid' and old.status is distinct from 'paid')
execute function supabase_functions.http_request(
'https://app-one.acme.supahost.space/functions/v1/invoice-paid',
'POST',
'{"Content-Type": "application/json", "Authorization": "Bearer <your anon key>"}',
'{}',
'5000'
);
The anon key is public anyway, so storing it in a trigger doesn't reveal anything new. Inside the function, use the injected SUPABASE_SERVICE_ROLE_KEY for any privileged work, and check a shared secret header if you need to be sure the call came from your database. See Authentication and JWT verification.
Use pg_net directly
For full control over the body, or to read secrets at call time, write your own trigger function and call pg_net yourself:
create or replace function private.post_new_message()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
v_token text;
begin
select decrypted_secret into v_token
from vault.decrypted_secrets
where name = 'chat_bot_token';
perform net.http_post(
url := 'https://chat.example.com/api/notify',
body := jsonb_build_object('channel', new.channel_id, 'text', left(new.body, 200)),
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || v_token
),
timeout_milliseconds := 5000
);
return new;
end;
$$;
create trigger messages_notify
after insert on public.messages
for each row
execute function private.post_new_message();
The token lives in Vault rather than in the function's source. See Storing secrets with Vault.
The pg_net functions you'll use:
| Function | Sends |
|---|---|
net.http_post(url, body jsonb, params jsonb, headers jsonb, timeout_milliseconds int) |
POST with a JSON body |
net.http_get(url, params jsonb, headers jsonb, timeout_milliseconds int) |
GET |
net.http_delete(url, params jsonb, headers jsonb, timeout_milliseconds int) |
DELETE |
Each returns a request id (bigint). You can also call them outside triggers, for example from a cron job; see Scheduled jobs with pg_cron.
Check what happened
-- latest responses
select id, status_code, timed_out, error_msg, left(content, 200) as body, created
from net._http_response
order by created desc
limit 20;
-- which webhook trigger made which request
select h.hook_name, h.created_at, r.status_code, r.error_msg
from supabase_functions.hooks h
left join net._http_response r on r.id = h.request_id
order by h.created_at desc
limit 20;
A null status code with timed_out = true means the remote side didn't answer within your timeout. Responses are cleaned up automatically after a few hours, so check soon after testing. More in Debugging database webhooks and pg_net.
When you need delivery guarantees
Because pg_net doesn't retry, don't rely on it alone for anything that must arrive (payments, emails to customers). Write the event to a queue in the same transaction instead, and have a cron job or worker send it and retry on failure. See Queues with pgmq and Processing queue messages.