Notícias19 min de leitura
Trigger no PostgreSQL: como disparar uma API direto do banco
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.
- postgresql
- trigger
- webhook
- pg_net
- api
- outbox
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 (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:
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 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:
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.
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"));
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
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.
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:
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).
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.
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.
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:
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ê:
{
"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.
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.
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:
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:
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:
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:
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:
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.
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.
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_pathvazio, nomes qualificados. EXECUTEnão está aberto paraPUBLICse a função forsecurity definer.- O payload lista campos. Não manda a linha inteira.
- O receptor trata
event_idrepetido como sucesso idempotente. - Timeout acima do tempo real da API. Dois segundos é o padrão, e gera
timed_outcom a API tendo processado. - Alerta em
status_code >= 400,error_msgoutimed_out. Não conte comnet._http_responsedaqui 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ãolocalhost.
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?
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 e código. 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, o atalho de tela sobre o mesmo trigger.
- pgsql-http, o cliente síncrono. Leia a diferença entre
http_postcomjsonbehttp_postcom content-type JSON antes de usar. - Transactional outbox, o padrão por trás da tabela deste artigo: gravar o evento na mesma transação do dado, e entregar fora.