Webhook Delivery no Node.js é o processo de enviar eventos HTTP para sistemas externos com persistência, retries, assinatura, idempotência e rastreabilidade. Uma chamada simples com fetch() pode funcionar em desenvolvimento, mas não oferece recuperação quando o destino está lento, indisponível ou responde de forma ambígua.
O emissor precisa separar a transação de negócio da tentativa de entrega. Primeiro, persiste o evento e a intenção de envio. Depois, um worker busca entregas pendentes, assina o corpo, envia com timeout e registra o resultado.
Modelo de dados
CREATE TABLE webhook_deliveries (
id uuid PRIMARY KEY,
endpoint_id uuid NOT NULL,
event_id uuid NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz,
last_status integer,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
delivered_at timestamptz
);Adicione índices por status e next_attempt_at. Não guarde respostas sensíveis completas sem necessidade.
Persistindo com Outbox
A intenção de webhook deve ser gravada na mesma transação da mudança de negócio.
await client.query('BEGIN');
await client.query(
`UPDATE orders SET status = 'paid' WHERE id = $1`,
[orderId]
);
await client.query(
`INSERT INTO outbox (id, type, aggregate_id, payload)
VALUES ($1, 'order.paid.v1', $2, $3::jsonb)`,
[eventId, orderId, JSON.stringify(event)]
);
await client.query('COMMIT');Um dispatcher transforma o evento da Outbox em entregas para endpoints inscritos. Veja Outbox Pattern no Node.js.
Selecionando trabalho
const result = await client.query(
`SELECT *
FROM webhook_deliveries
WHERE status IN ('pending', 'retry')
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 50`
);SKIP LOCKED permite vários workers sem processar a mesma linha ao mesmo tempo. Mantenha a transação curta e marque as linhas como em processamento antes da chamada externa.
Payload estável
{
"id": "evt_123",
"type": "order.paid.v1",
"createdAt": "2026-10-24T12:00:00Z",
"data": {
"orderId": "ord_45",
"amount": 12990,
"currency": "BRL"
}
}Inclua ID estável, tipo versionado e timestamp. O consumidor usa o ID para deduplicar.
Assinando a entrega
Calcule HMAC sobre timestamp e bytes exatos do corpo.
const rawBody = Buffer.from(JSON.stringify(payload));
const timestamp = Math.floor(Date.now() / 1000);
const signature = signWebhook({
secret: endpoint.secret,
timestamp,
rawBody
});Consulte Webhook Signatures no Node.js.
Envio com timeout
const signal = AbortSignal.timeout(10_000);
const response = await fetch(endpoint.url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'user-agent': 'CodigoFacil-Webhooks/1.0',
'webhook-id': payload.id,
'webhook-timestamp': String(timestamp),
'webhook-signature': signature
},
body: rawBody,
signal
});Defina limite por conexão, headers e corpo de resposta. Não espere indefinidamente.
Quais respostas indicam sucesso?
Normalmente qualquer 2xx confirma aceitação. Redirecionamentos devem ser tratados com cautela; seguir redirect automaticamente pode enviar segredos e payloads para outro host.
const success = response.status >= 200 && response.status < 300;Configure redirect: 'manual' e exija que o cliente atualize o endpoint explicitamente.
Erros recuperáveis
Faça retry para:
- timeout;
- falha de conexão;
408;429;500,502,503e504.
Respostas como 400, 401, 403 e 404 geralmente exigem correção de configuração, não retry infinito.
Backoff com jitter
function nextDelay(attempt) {
const base = 5_000;
const cap = 6 * 60 * 60 * 1000;
const exponential = Math.min(cap, base * 2 ** attempt);
return Math.floor(Math.random() * exponential);
}Respeite Retry-After quando confiável e dentro de limites. Veja Retry com Backoff no Node.js.
Agenda de tentativas
Um exemplo de política:
- imediata;
- 30 segundos;
- 2 minutos;
- 10 minutos;
- 1 hora;
- 6 horas;
- 24 horas.
Depois do limite, marque como falha permanente e notifique o proprietário do endpoint.
Idempotência do consumidor
Uma resposta pode se perder depois que o destino processou o evento. O emissor fará retry. Por isso, o consumidor deve deduplicar pelo ID do evento.
Consulte Consumer Idempotente no Node.js.
Concorrência por endpoint
Entregar muitos eventos simultaneamente pode derrubar o cliente. Use limite global e limite por endpoint.
const endpointLimiter = new Map();
async function withEndpointLimit(endpointId, task) {
const limiter = getLimiter(endpointId, 3);
return limiter.run(task);
}Preservar ordem exige concorrência 1 por chave ou uma sequência lógica. Nem todos os eventos precisam de ordem total.
Ordenação
Webhooks são naturalmente sujeitos a atraso e retries. Inclua versão do recurso ou timestamp para que o consumidor detecte eventos antigos.
{
"type": "customer.updated.v2",
"resourceVersion": 18
}Para estado atual, o consumidor pode buscar o recurso pela API em vez de confiar que recebeu todos os eventos na ordem.
SSRF
Endpoints configurados por usuários criam risco de Server-Side Request Forgery. Bloqueie IPs privados, loopback, metadata de nuvem e protocolos não HTTP.
- aceite apenas HTTPS em produção;
- resolva DNS e valide todos os endereços;
- revalide após redirect, ou não siga redirects;
- limite portas;
- use egress proxy quando possível;
- proteja contra DNS rebinding.
Desativação automática
Após muitas falhas permanentes, pause o endpoint. Envie alerta e permita reativação após teste de conectividade.
Replay manual
Forneça uma operação administrativa auditada para reenviar uma entrega. O replay deve usar o mesmo ID de evento ou indicar explicitamente que é uma nova tentativa, mantendo deduplicação.
Dead Letter
Entregas esgotadas podem ficar em estado dead. Não as descarte automaticamente; mantenha retenção e ferramentas de diagnóstico.
Veja Dead Letter Queue no Node.js.
Observabilidade
Monitore:
- taxa de sucesso;
- latência por endpoint;
- tentativas por evento;
- fila pendente;
- idade do item mais antigo;
- falhas por status;
- endpoints pausados;
- tempo até entrega.
Inclua deliveryId, eventId e endpointId em logs e traces, sem registrar segredo ou payload completo.
Resposta do destino
Leia no máximo alguns kilobytes para diagnóstico. Um servidor malicioso pode enviar resposta infinita.
const text = await readResponseWithLimit(response.body, 8 * 1024);Testes
- 2xx;
- timeout;
- 429 com Retry-After;
- 500 seguido de sucesso;
- 400 permanente;
- redirect;
- DNS para IP privado;
- resposta grande;
- evento duplicado;
- dois workers concorrentes;
- replay manual;
- rotação de segredo.
Erros comuns
- enviar dentro da transação de negócio;
- não persistir tentativas;
- retry sem limite;
- seguir redirects automaticamente;
- não proteger contra SSRF;
- não incluir ID estável;
- concorrência ilimitada;
- esperar ordem perfeita;
- registrar payload sensível;
- não oferecer replay.
Checklist
- Outbox persistida;
- delivery por endpoint;
- assinatura e timestamp;
- timeout;
- retries com jitter;
- classificação de status;
- idempotência;
- limite por endpoint;
- proteção SSRF;
- DLQ e replay;
- métricas e alertas.
Conclusão
Webhook Delivery no Node.js exige uma fila persistente e política operacional clara. A entrega HTTP é apenas uma tentativa dentro de um ciclo que inclui assinatura, timeout, retry e observabilidade.
Use Outbox, IDs estáveis, backoff e limites de concorrência. Proteja URLs contra SSRF e permita replay auditado. Com esses componentes, webhooks continuam confiáveis mesmo quando sistemas externos falham por horas.
Consulte a documentação do fetch no Node.js e o RFC de semântica HTTP.



