Webhooks e novas tentativas em API
Como construir um consumidor de webhook confiável, incluindo novas tentativas e idempotência.
Um webhook é a diferença entre descobrir que um post falhou no momento em que falhou e descobrir três dias depois, quando um cliente pergunta por que o anúncio nunca saiu. Mas um consumidor de webhook mal construído troca um problema por outro: em vez de ficar no escuro, você fica exposto a eventos duplicados, fora de ordem, ou perdidos silenciosamente durante uma instabilidade da sua própria infraestrutura. Construir um consumidor confiável não é complicado, mas exige alguns cuidados que não são óbvios na primeira implementação.
Assine só os eventos que o seu produto usa
Ao criar uma assinatura, você escolhe explicitamente quais tópicos quer receber em events[] — não existe um "assinar tudo" por padrão. O segredo de assinatura (signing secret) é retornado uma única vez na criação; salve-o imediatamente, porque ele não aparece de novo em nenhuma listagem posterior.
curl -X POST https://api.postgate.studio/api/webhooks \
-H "Authorization: Bearer $POSTGATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.exemplo.com/webhooks/postgate",
"events": ["post.published", "post.partial", "post.failed"]
}'Verifique o corpo bruto da requisição
O PostGate assina <timestamp>.<corpo-bruto> com HMAC-SHA256. Extraia os valores t e v1 do cabeçalho X-PostGate-Signature, rejeite timestamps antigos demais e compare os digests em tempo constante — nunca com um simples ===, que vaza timing e abre espaço para ataque. O ID estável do evento também é enviado como X-PostGate-Event-Id, e é ele que você deve usar para deduplicação, não um hash que você mesmo calcula a partir do corpo.
Um erro comum aqui é validar a assinatura em cima do corpo já parseado como JSON pelo seu framework web, em vez do corpo bruto de bytes recebido. Reserializar um objeto quase sempre produz uma string byte-a-byte diferente do que foi assinado — espaços, ordem de chaves e escaping variam entre serializadores — e a verificação falha mesmo com um payload legítimo. Capture o corpo bruto antes de qualquer middleware de parsing tocar nele.
t=1785254400,v1=4f7c...9a2eConfirme o recebimento antes de processar
Verifique a assinatura, persista ou enfileire o evento, e só então retorne uma resposta 2xx. A entrega é reenviada automaticamente quando o seu endpoint expira (timeout) ou retorna um status que não é 2xx — então o padrão correto é responder rápido e processar depois, de forma assíncrona, em vez de rodar toda a sua lógica de negócio dentro da própria requisição do webhook. Um endpoint que demora para processar antes de responder é indistinguível, do ponto de vista do PostGate, de um endpoint fora do ar — e ambos geram a mesma cadeia de retentativas.
Depois de implantar um novo endpoint, ou sempre que você rotacionar infraestrutura, use POST /api/webhooks/{id}/test para disparar uma entrega de teste e confirmar que o caminho completo — assinatura, parsing, resposta — realmente funciona antes de depender dele em produção.
Por que o seu consumidor precisa ser idempotente
Retentativas significam que o mesmo evento pode chegar mais de uma vez — isso não é um bug do PostGate, é a consequência inevitável de garantir "pelo menos uma entrega" em vez de "no máximo uma". Deduplicar pelo id do evento é obrigatório: antes de processar, cheque se aquele ID já foi tratado (uma tabela simples com uma coluna única de event_id resolve isso) e, se já foi, responda 2xx sem reexecutar o efeito colateral.
Todo evento carrega um envelope estável — id, type, occurred_at, e um objeto data específico do recurso. Ao repassar ou logar eventos internamente, preserve campos desconhecidos: isso mantém o seu próprio pipeline compatível com o futuro quando o PostGate adicionar informação nova a um evento existente.
O catálogo de eventos que importa para publicação
Para a maioria das integrações de publicação, os eventos realmente decisivos são um subconjunto pequeno: post.published, post.partial e post.failed fecham o ciclo de vida de um post — é isso que substitui o polling. profile.expiring, profile.expired e profile.reauth_required cobrem o lado de conexão, cada um com um reason específico (refresh_failed, revoked ou publish_auth_error) para que a mensagem exibida ao cliente nomeie a causa real em vez de um genérico "algo deu errado". Ambos disparam uma vez por transição de estado de autorização, não a cada varredura de manutenção — então a caixa de entrada do seu cliente não enche de alertas repetidos sobre a mesma conexão quebrada.
Assine apenas os tópicos que o seu produto realmente consome. Assinar tudo "só para garantir" aumenta a superfície de eventos que o seu consumidor precisa tratar corretamente, sem nenhum ganho real.
