---
title: "Trigger no PostgreSQL: como disparar uma API direto do banco"
description: "Passo a passo para disparar uma API com trigger no PostgreSQL usando pg_net: como testar, prós, contras e quando o webhook direto do banco não basta."
date: "2026-09-16"
tags: ["postgresql", "trigger", "webhook", "pg_net", "api", "outbox"]
url: "https://abjr.dev/noticias/trigger-postgresql-disparar-api"
---

Você gravou um pedido e precisa avisar um sistema de fora. A tentação é um trigger no PostgreSQL que dá um POST e acabou. Dá para disparar uma API direto do banco. O Postgres, sozinho, não fala HTTP: quem faz a chamada é uma extensão, ou um processo que lê uma fila que o trigger só enfileirou.

Os três caminhos não são equivalentes. Um espera a API e segura o `INSERT`. Outro dispara depois do `COMMIT` e pode perder o evento. O terceiro, o outbox, é o único que eu usaria quando o webhook não pode sumir. Este texto é o passo a passo do laboratório com `pg_net`, os testes que evitam falsa confiança, e o ponto em que vale parar e tirar o HTTP do banco.

## O que um trigger faz, e o que ele não faz

Um trigger é uma função que o PostgreSQL executa por causa de um `INSERT`, `UPDATE`, `DELETE` ou `TRUNCATE`. Em `AFTER INSERT ... FOR EACH ROW`, a função vê a linha nova em `NEW`. Em `UPDATE`, `OLD` é o valor anterior e `NEW` é o novo. Em `DELETE`, só existe `OLD`.

Isso resolve consistência dentro do banco: preencher coluna, impedir um estado inválido, gravar auditoria na mesma transação. Não resolve rede. Uma chamada HTTP não entra no `COMMIT`. Se a API responder 201 e a transação der rollback em seguida, o sistema de fora já acha que o pedido existe. Se a API cair e você engolir o erro, o pedido existe e ninguém ficou sabendo.

`LISTEN`/`NOTIFY` também não é webhook. `pg_notify` acorda quem estiver escutando. Se ninguém escuta, o aviso se perde. Não há retry, não há fila, e o payload para em 8 KB. Serve para acordar um worker. Não serve para entregar um evento.

## Três jeitos de reagir a uma mudança

| Caminho | O `INSERT` espera a API? | Se der rollback | Se a API cair | Retry | RDS / Aurora |
| --- | --- | --- | --- | --- | --- |
| Extensão `http` (pgsql-http) | Sim | A chamada pode já ter saído | Pode desfazer o `INSERT`, ou perder o evento | Não | Quase nunca |
| `pg_net` | Não. Só sai depois do `COMMIT` | Não dispara | O `INSERT` fica. O evento se perde | Não | Não |
| Outbox + worker | Não | A linha da fila também desfaz | O evento continua na tabela | Sim | Sim |

### A extensão `http`, síncrona

A [pgsql-http](https://github.com/pramsey/pgsql-http) (`CREATE EXTENSION http`) chama a API na hora, com cURL, e devolve status e corpo. A sessão fica parada até o timeout. Dentro de um trigger, o `INSERT` fica parado junto, segurando lock.

Há um detalhe que quebra o primeiro teste: `http_post(url, jsonb)` não manda JSON. Manda `application/x-www-form-urlencoded`. JSON é a sobrecarga de três argumentos:

```sql
select status, content
from http_post(
  'https://httpbin.org/post',
  '{"ping": true}',
  'application/json'
);
```

Ela aceita GET, POST, PUT, PATCH, DELETE e HEAD. Útil em script, migração e operação rara, quando você precisa da resposta para decidir o que fazer. Em tabela quente, a latência da API vira latência do banco. Um 500 não tratado desfaz o `INSERT`. Um `EXCEPTION` que engole o erro grava o dado e perde o aviso. Os dois resultados são ruins, e os dois são fáceis de escrever.

Se a resposta da API precisa decidir a linha, faça isso na aplicação, com timeout seu. Trigger síncrono é o lugar errado para esperar rede.

### `pg_net`, assíncrona

O [`pg_net`](https://github.com/supabase/pg_net) coloca o pedido numa fila e um worker em background faz o HTTP. A função devolve na hora um `bigint` (o id do pedido), não a resposta. A documentação é explícita: a requisição não começa antes do `COMMIT`. Rollback desfaz a linha da fila, e o webhook não sai. É o comportamento certo, e é o que a maioria das pessoas quer dizer com "disparar no trigger".

O worker cobre `net.http_get`, `net.http_post` (só JSON) e `net.http_delete`. Não há PUT nem PATCH. O timeout padrão é 2000 ms. A resposta cai em `net._http_response` e, por padrão, some em 6 horas (`pg_net.ttl`). Fila e resposta são tabelas unlogged: crash ou desligamento sujo apaga o que ainda não foi entregue, e isso não vai para réplica.

Os limites que importam em produção, da própria extensão:

- entrega no máximo uma vez, sem retry automático
- cerca de 200 requisições por segundo; passar disso instabiliza
- um banco por cluster, o padrão é `postgres` (`pg_net.database_name`)
- a API ainda está em beta, e a assinatura das funções pode mudar

Os Database Webhooks do Supabase são um atalho em cima disso: um trigger que chama `pg_net` (no dashboard, costuma aparecer `supabase_functions.http_request`). O SQL deste artigo é o mesmo mecanismo, sem a tela.

### Outbox transacional

O trigger não chama ninguém. Na mesma transação do pedido, ele insere um evento numa tabela `outbox`. Ou os dois commitaram, ou nenhum. Um worker (a aplicação, ou `pg_cron` lendo a tabela) entrega, tenta de novo com backoff e, depois de N falhas, manda para dead letter.

A entrega é pelo menos uma vez. O receptor precisa ser idempotente. `NOTIFY` só acorda o worker; um poll de poucos segundos cobre o que o notify perde. Funciona em qualquer PostgreSQL, inclusive onde não existe extensão HTTP.

## Onde isso roda

Self-hosted: Postgres 12 ou mais (na prática, 14+), libcurl 7.83 ou mais, `shared_preload_libraries = 'pg_net'` e um restart. Depois, `CREATE EXTENSION pg_net`. A extensão cria o schema `net`. Se a aplicação não usa o banco `postgres`, ajuste `pg_net.database_name` e reinicie o worker (`select net.worker_restart()`). Sem isso, o trigger enfileira num banco e o worker lê outro. O evento nunca sai, e o `INSERT` parece sucesso.

No Supabase, a extensão já vem no preload. Habilite em Database, Extensions, ou:

```sql
create extension if not exists pg_net with schema extensions;
```

As funções continuam em `net.http_post`. O `WITH SCHEMA extensions` só evita instalar objeto em `public`.

Amazon RDS e Aurora não oferecem `pg_net`. Lá, o equivalente proprietário é invocar Lambda a partir do SQL (`aws_lambda`), que é outro produto, com outra conta e outro timeout. No Cloud SQL e em qualquer Postgres gerenciado, olhe a lista de extensões antes de desenhar a feature. Se `CREATE EXTENSION pg_net` falhar, o desenho é outbox, não trigger HTTP.

## Laboratório: trigger com pg_net

A ordem evita o erro clássico: primeiro a chamada solta, depois o trigger. Suba o receptor antes do banco.

### 1. Um receptor mínimo

Na máquina host, não dentro do container do Postgres. Porta 8787 para não brigar com um `next dev` na 3000.

```js
const http = require("node:http");

const TOKEN = "troque-isto";
const seen = new Set();

http
  .createServer((req, res) => {
    if (req.method !== "POST" || req.url !== "/webhooks/pedidos") {
      res.writeHead(404);
      res.end();
      return;
    }

    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const raw = Buffer.concat(chunks).toString("utf8");
      if (req.headers["x-webhook-token"] !== TOKEN) {
        res.writeHead(401);
        res.end("token invalido");
        return;
      }

      const event = JSON.parse(raw);
      if (seen.has(event.event_id)) {
        console.log("duplicado", event.event_id);
        res.writeHead(200);
        res.end("duplicate");
        return;
      }

      seen.add(event.event_id);
      console.log("pedido", event.pedido.id, event.event_id);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    });
  })
  .listen(8787, () => console.log("ouvindo em :8787"));
```

```bash
node receptor.js
```

Se o Postgres está no host, a URL do trigger é `http://127.0.0.1:8787/webhooks/pedidos`. Se está num container (Supabase local inclusive), `localhost` é o container, não a sua máquina. Use `http://host.docker.internal:8787/webhooks/pedidos`. No Docker do Linux isso só resolve se o compose tiver `extra_hosts: ["host.docker.internal:host-gateway"]`.

### 2. Confirme a extensão, fora do trigger

```sql
create extension if not exists pg_net;

select net.http_post(
  url := 'https://httpbin.org/post',
  body := '{"ping": true}'::jsonb,
  headers := '{"Content-Type": "application/json"}'::jsonb,
  timeout_milliseconds := 5000
) as request_id;
```

Espere um ou dois segundos. O worker é assíncrono; consultar na mesma fração de segundo devolve vazio e parece bug.

```sql
select id, status_code, timed_out, error_msg, left(content, 180) as content, created
from net._http_response
order by created desc
limit 5;
```

`status_code` 200 e um corpo de eco: a extensão fala com a internet. Se a linha ficou em `net.http_request_queue` e não anda, o worker não está lendo este banco. Confira `shared_preload_libraries` e `pg_net.database_name`:

```sql
select name, setting
from pg_settings
where name like 'pg_net%';

select id, method, url
from net.http_request_queue;
```

### 3. Tabela, segredo e função

Não grave o token no corpo da função. `pg_proc` mostra o fonte para quem puder ler o catálogo, e um dump leva o segredo junto. No laboratório, um parâmetro de sessão. Em produção, o Vault (abaixo).

```sql
set app.webhook_secret = 'troque-isto';
```

Isso vale para a sessão atual. Para toda conexão nova: `alter database postgres set app.webhook_secret = 'troque-isto';` e reconecte. Não é cofre. Qualquer papel que leia a configuração vê o valor. Serve para o teste não nascer com segredo no SQL do artigo.

A URL fica fixa na função. Nunca monte o endpoint com dado da linha. Um cliente que grave `https://169.254.169.254/` num campo, e um trigger que use esse campo, é SSRF a partir do banco, com a rede do servidor.

`search_path` vazio e nomes qualificados. Sem isso, o trigger quebra quando o papel que fez o `INSERT` não enxerga o schema `net` — comum no Supabase, em que o papel muda conforme a origem da escrita.

```sql
create table public.pedidos (
  id bigint generated always as identity primary key,
  cliente text not null,
  total numeric(12, 2) not null check (total >= 0),
  status text not null default 'novo',
  criado_em timestamptz not null default now()
);

create or replace function public.notificar_pedido()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
  secret text := current_setting('app.webhook_secret', true);
  event_id uuid := gen_random_uuid();
  request_id bigint;
begin
  if secret is null or secret = '' then
    raise exception 'app.webhook_secret não configurado';
  end if;

  request_id := net.http_post(
    url := 'http://host.docker.internal:8787/webhooks/pedidos',
    body := jsonb_build_object(
      'event_id', event_id,
      'event', 'pedido.criado',
      'occurred_at', now(),
      'pedido', jsonb_build_object(
        'id', new.id,
        'cliente', new.cliente,
        'total', new.total,
        'status', new.status
      )
    ),
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'X-Webhook-Token', secret
    ),
    timeout_milliseconds := 5000
  );

  raise log 'webhook enfileirado request_id=% event_id=% pedido=%',
    request_id, event_id, new.id;

  return new;
end;
$$;
```

Troque a URL se o Postgres não está em Docker. `gen_random_uuid()` está no core desde o PostgreSQL 13; no 12, instale `pgcrypto`.

O payload manda os campos que o receptor precisa, não `to_jsonb(new)`. Atalho de `to_jsonb` parece prático até alguém adicionar `cpf` ou `token` na tabela e o webhook passar a vazar.

Não envolva o `net.http_post` num `EXCEPTION WHEN OTHERS` que retorna `NEW` em silêncio. Essa exceção não é o 500 da API. A API ainda nem foi chamada. É falha de enfileirar: extensão ausente, permissão, URL nula. Engolir isso grava o pedido e perde o evento sem rastro. Deixe a transação falhar.

### 4. O trigger

`AFTER`, não `BEFORE`. Em `BEFORE`, a linha ainda pode ser recusada por outro trigger ou por uma constraint adiada, e você não quer notificar o que não entrou.

```sql
create trigger pedidos_notificar
after insert on public.pedidos
for each row
execute function public.notificar_pedido();
```

Em servidor anterior ao PostgreSQL 14, se o comando reclamar da sintaxe, use `execute procedure`. O efeito é o mesmo.

`UPDATE` e `DELETE` são o mesmo desenho, com `tg_op` no payload e `old` só quando existir. Em trigger `AFTER`, o valor de retorno é ignorado; mesmo assim, retorne `NEW` em insert/update e `OLD` em delete, para o dia em que alguém mudar para `BEFORE`.

`FOR EACH ROW` em carga grande vira uma requisição por linha. Mil inserts, mil POSTs, e o teto do `pg_net` é cerca de 200 por segundo. Para lote, `FOR EACH STATEMENT` com transition tables (`referencing new table as novas`) junta as linhas num JSON e faz um POST. Limite o tamanho do lote. Um body enorme também estoura o receptor.

## Como testar sem se enganar

Cinco testes, nesta ordem. O primeiro sozinho só prova que o caminho feliz existe.

**1. Commit entrega.** Com o receptor ligado e o segredo configurado nesta sessão:

```sql
insert into public.pedidos (cliente, total)
values ('Ada', 42.50)
returning id;
```

O `RETURNING` volta na hora. O log do Node chega um instante depois. Se você consultar `net._http_response` no mesmo piscar de olhos e não achar a linha, espere e consulte de novo. Não é falha do trigger.

O corpo que o receptor vê:

```json
{
  "event_id": "0c2f1a3e-7b8d-4e1f-9a6c-2d5e8f0a1b34",
  "event": "pedido.criado",
  "occurred_at": "2026-09-16T15:00:00+00:00",
  "pedido": { "id": 1, "cliente": "Ada", "total": 42.5, "status": "novo" }
}
```

**2. A resposta fica numa tabela, por pouco tempo.**

```sql
select id, status_code, timed_out, error_msg, created
from net._http_response
order by created desc
limit 10;
```

`status_code >= 400`, `error_msg` preenchido ou `timed_out` são falha. Essa tabela não é log de auditoria: unlogged, 6 horas, some no crash. Se você precisa do histórico, grave fora dela.

**3. Rollback não dispara.** Este é o teste que o `pg_net` acerta e a extensão `http` erra, se a chamada síncrona já tiver saído.

```sql
begin;
insert into public.pedidos (cliente, total) values ('não deve sair', 1);
select id, url from net.http_request_queue;
rollback;
```

Dentro da transação, a fila pode mostrar a linha. Depois do rollback, some. O receptor fica quieto. Confirme:

```sql
select count(*) from public.pedidos where cliente = 'não deve sair';
select id, url from net.http_request_queue;
```

Zero e vazio. Se o Node imprimiu esse cliente, você não está no `pg_net`: está numa chamada síncrona, ou commitou sem querer.

**4. API fora do ar não desfaz o pedido, e ninguém tenta de novo.** Aponte a URL da função para uma porta fechada (`http://127.0.0.1:9/webhooks/pedidos`), ou desligue o Node, e insira outra linha. O `INSERT` commita. Segundos depois:

```sql
select status_code, timed_out, error_msg, created
from net._http_response
order by created desc
limit 5;
```

Você vê `error_msg` ou `timed_out`. Não há segunda tentativa. O pedido está no banco e o webhook morreu. Se isso for inaceitável, este não é o desenho. É o teste que decide.

**5. O mesmo `event_id` duas vezes não faz o efeito duas vezes.** Um segundo `INSERT` é outro evento, com outro uuid. O teste de idempotência é reentregar o mesmo JSON. Pegue o `event_id` do log e mande de novo:

```bash
curl -s -X POST http://127.0.0.1:8787/webhooks/pedidos \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Token: troque-isto' \
  -d '{"event_id":"COLE-O-UUID","event":"pedido.criado","pedido":{"id":1}}'
```

A primeira resposta processa. A segunda imprime `duplicado` e devolve `duplicate`, sem segundo efeito. O `pg_net` não faz esse retry sozinho, mas timeout mente: a API pode ter processado e a resposta ter estourado os 5 segundos. Quem reenvia na mão, e qualquer outbox, cai neste caso. Idempotência não é opcional só porque a extensão "não retenta".

## Segredo, assinatura e o que não colocar no trigger

Token estático no header vaza em log de proxy e em `net.http_request_queue`, que o schema `net` libera `USAGE` para `PUBLIC`. No Supabase isso não aparece na Data API, e os papéis `anon` / `authenticated` são `NOLOGIN`. Em Postgres aberto, quem consegue `SELECT` nessa fila lê o token. Restrinja.

No Supabase, o caminho de produção é o Vault, com função `security definer`, dono que possa ler o segredo, e `search_path` vazio:

```sql
create or replace function public.notificar_pedido()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
  secret text;
begin
  select decrypted_secret into secret
  from vault.decrypted_secrets
  where name = 'webhook_secret';

  if secret is null then
    raise exception 'webhook_secret ausente no vault';
  end if;

  perform net.http_post(
    url := 'https://api.exemplo.com/webhooks/pedidos',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'X-Webhook-Token', secret
    ),
    body := jsonb_build_object('pedido_id', new.id)
  );

  return new;
end;
$$;
```

`security definer` executa como o dono da função. Se o dono for superusuário e o `search_path` não estiver travado, a função vira vetor. Trave o path, revogue `EXECUTE` de `PUBLIC` e conceda só aos papéis que escrevem na tabela.

Assinar o body byte a byte é armadilha com `pg_net`: a função recebe `jsonb`, e `jsonb` não preserva a ordem das chaves. O texto que você assinou pode não ser o texto que o worker enviou. Assine uma string que você controla, e que o receptor reconstrói dos campos, não dos bytes crus:

```text
event_id || '.' || pedido_id || '.' || occurred_at
```

No banco, `hmac()` vem do `pgcrypto` (`extensions.hmac` no Supabase). No Node, `crypto.createHmac('sha256', secret)` e `timingSafeEqual`. Token no header protege o laboratório. HMAC dessa string canônica é o que eu deixaria em produção.

## Esboço do outbox

Isto não é um framework. É o mínimo para o evento sobreviver a um crash do worker. O trigger só insere. Quem fala HTTP é outro processo.

```sql
create table public.outbox (
  id bigint generated always as identity primary key,
  event_id uuid not null unique,
  event_type text not null,
  payload jsonb not null,
  status text not null default 'pending'
    check (status in ('pending', 'processing', 'sent', 'dead')),
  attempts int not null default 0,
  next_attempt_at timestamptz not null default now(),
  locked_at timestamptz,
  last_error text,
  created_at timestamptz not null default now(),
  sent_at timestamptz
);

create index outbox_pendentes_idx
  on public.outbox (next_attempt_at)
  where status in ('pending', 'processing');

create or replace function public.enfileirar_pedido()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
  event_id uuid := gen_random_uuid();
begin
  insert into public.outbox (event_id, event_type, payload)
  values (
    event_id,
    'pedido.criado',
    jsonb_build_object(
      'event_id', event_id,
      'event', 'pedido.criado',
      'occurred_at', now(),
      'pedido', jsonb_build_object(
        'id', new.id,
        'cliente', new.cliente,
        'total', new.total,
        'status', new.status
      )
    )
  );

  perform pg_notify('outbox', '');
  return new;
end;
$$;

create trigger pedidos_outbox
after insert on public.pedidos
for each row
execute function public.enfileirar_pedido();
```

O worker, em transação curta, reivindica linhas e solta o lock antes da rede. Segurar `FOR UPDATE` durante o POST é como a extensão `http`: a latência de fora vira lock no banco.

```sql
update public.outbox
set status = 'processing',
    locked_at = now()
where id in (
  select id
  from public.outbox
  where (
    status = 'pending'
    and next_attempt_at <= now()
  ) or (
    status = 'processing'
    and locked_at < now() - interval '2 minutes'
  )
  order by id
  for update skip locked
  limit 10
)
returning id, event_id, payload;
```

O `or` do lease devolve linha cujo worker morreu no meio. Dois workers não pegam a mesma linha por causa do `skip locked`.

Depois do HTTP, em outra transação: 2xx marca `sent` e `sent_at`. Qualquer outra coisa incrementa `attempts`, grava `last_error` e empurra `next_attempt_at` (`interval '1 second' * power(2, attempts)`, com teto). Na oitava falha, `status = 'dead'`. Alerta em dead letter. Não apague a linha para "limpar": ela é o recibo de que você tentou.

O `pg_notify` no trigger acorda quem está em `LISTEN outbox`. Se o worker estava fora, o notify morreu. Um poll de 5 segundos pega o que ficou. Use os dois. Não use o notify como fila.

O receptor é o mesmo do laboratório. A garantia de "pelo menos uma vez" significa que o POST pode sair duas vezes se o processo cair depois do 200 e antes do `UPDATE`. Sem o `event_id` gravado na tabela, o retry não teria o que repetir. Por isso o uuid nasce na linha, não só no JSON descartável do `pg_net`.

## Prós, contras e quando não usar

**Facilidades do trigger com `pg_net`.** Não há serviço extra para um aviso simples. O payload já tem a linha, no dialeto do banco, sem um ORM no meio. O POST não sai em rollback. Dá para testar a chamada no SQL antes de amarrar o trigger. No Supabase, o dashboard cria o mesmo fluxo. Combinado com `pg_cron`, o banco também chama uma API de minuto em minuto, sem trigger nenhum.

**Contras.** O banco vira cliente HTTP: timeout, DNS, certificado e fila passam a ser problema de DBA. O segredo mora perto dos dados. Debugar é olhar tabela unlogged que some, não um log de aplicação. Crash entre o commit e o worker perde o evento, e não há retry. O teto de 200 req/s e o limite de um banco por cluster aparecem tarde, em produção. Cada coluna nova no payload é contrato quebrado com quem consome o webhook. A API em beta pode mudar de assinatura.

**Regra prática.** Notificação que pode falhar e ser refeita na mão, volume baixo, `pg_net` disponível: o trigger assíncrono serve. Dinheiro, estoque, e-mail transacional, ou qualquer efeito que o usuário acredita que aconteceu porque a tela disse "salvo": outbox ou fila fora do banco. Se a extensão não existe no provedor, nem comece pelo `pg_net`.

## Checklist de produção

- A extensão existe no Postgres de produção, não só no Docker local.
- `pg_net.database_name` é o banco em que o trigger roda, e o worker foi reiniciado depois da mudança.
- A URL é constante no código. Nenhum campo da linha escolhe o host.
- O segredo não está no fonte da função. Vault, ou equivalente. `search_path` vazio, nomes qualificados.
- `EXECUTE` não está aberto para `PUBLIC` se a função for `security definer`.
- O payload lista campos. Não manda a linha inteira.
- O receptor trata `event_id` repetido como sucesso idempotente.
- Timeout acima do tempo real da API. Dois segundos é o padrão, e gera `timed_out` com a API tendo processado.
- Alerta em `status_code >= 400`, `error_msg` ou `timed_out`. Não conte com `net._http_response` daqui a um dia.
- Carga estimada folgada em relação a 200 requisições por segundo, ou o desenho já é outbox.
- No Supabase local, a URL usa `host.docker.internal`, não `localhost`.

## Perguntas frequentes

### O trigger espera a API responder?

Com `pg_net`, não. O `INSERT` espera só a gravação na fila, e a requisição começa depois do `COMMIT`. Com a extensão `http`, sim: a sessão bloqueia até o timeout, e um erro não tratado desfaz a transação.

### Rollback dispara o webhook?

Com `pg_net`, não. A linha da fila participa da transação. Confirme com `BEGIN` / `INSERT` / `ROLLBACK` e o receptor em silêncio. Com HTTP síncrono dentro do trigger, a chamada pode já ter saído antes do rollback. Aí o sistema de fora viu um pedido que o banco não tem.

### Dá para usar no RDS?

`pg_net` não está no RDS nem no Aurora. O padrão outbox funciona, porque é tabela e worker. Invocar Lambda a partir do SQL é outro recurso, específico da AWS, e não substitui retry nem idempotência.

### Como ver o erro da chamada?

```sql
select id, status_code, error_msg, timed_out, created
from net._http_response
where status_code >= 400
   or error_msg is not null
   or timed_out
order by created desc;
```

Se não há linha nem na fila nem na resposta, ou o worker ainda não pegou (espere), ou você está no banco errado, ou a transação não commitou.

### Posso mandar PUT ou PATCH?

Não pelo `pg_net`. Só GET, POST com JSON e DELETE. PUT, PATCH e outros content-types pedem a extensão `http` (síncrona) ou um worker no outbox, que usa o cliente HTTP que você quiser.

### `LISTEN`/`NOTIFY` substitui o webhook?

Não. É um sinal, não uma entrega. Use para acordar o worker do outbox, com poll por baixo.

## Referências

- [pg_net: documentação](https://supabase.com/docs/guides/database/extensions/pg_net) e [código](https://github.com/supabase/pg_net). Assinaturas, o fato de o HTTP só sair após o commit, TTL, tabelas unlogged e o teto de vazão estão lá. A API é beta.
- [Database Webhooks do Supabase](https://supabase.com/docs/guides/database/webhooks), o atalho de tela sobre o mesmo trigger.
- [pgsql-http](https://github.com/pramsey/pgsql-http), o cliente síncrono. Leia a diferença entre `http_post` com `jsonb` e `http_post` com content-type JSON antes de usar.
- [Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html), o padrão por trás da tabela deste artigo: gravar o evento na mesma transação do dado, e entregar fora.
