# 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.

Source: https://supahost.app/docs/database/webhooks · Updated 2026-11-11

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_response` under the same request ID. In a test, a request took about 0.65 seconds from `net.http_get` to 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.

```sql
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:

```json
{
  "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:

```sql
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](/docs/functions/auth).

## 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:

```sql
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](/docs/database/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](/docs/cron-queues/cron).

## Check what happened

```sql
-- 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](/kb/webhook-debugging).

## 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](/docs/cron-queues/queues) and [Processing queue messages](/docs/cron-queues/queue-consumers).

## Related

- [Triggers](/docs/database/triggers)
- [Calling functions from your app](/docs/functions/calling-functions)
- [Postgres extensions](/docs/database/extensions)
