Webhooks de pagamento na prática: assinatura, idempotência e retentativa
Como receber notificações de pagamento com segurança na API Pagnovo — validação HMAC, idempotência, resposta rápida, reentrega e monitoramento de entregas.
Equipe Pagnovo · 2026-08-05
O webhook é o ponto onde seu sistema descobre que o dinheiro entrou. Se ele falha, o cliente paga e o pedido não libera. Se ele é inseguro, alguém marca pedidos como pagos sem pagar. Vale investir os 30 minutos que este guia leva.
Referência completa: portal.pagnovo.com/docs.
Por que não basta a resposta da API
Ao criar uma cobrança PIX, você recebe status: "PENDING". O pagamento acontece depois, quando o
cliente abre o app do banco dele. Não existe resposta síncrona que diga "pago" — a confirmação chega
de forma assíncrona, pelo webhook.
Projetar o sistema assumindo o contrário é o erro nº 1 de quem está começando.
Registrando o webhook
Na Pagnovo você registra o endpoint uma vez e escolhe quais eventos quer receber:
POST /v2/webhooks
{
"url": "https://sua-app.com/webhooks/pagnovo",
"description": "Produção",
"events": ["cashin.paid", "cashin.refunded", "cashout.success", "cashout.failed"]
}
A resposta devolve um secret em texto puro — uma única vez:
{ "id": "...", "secret": "..." }
Guarde-o em cofre de segredos (não no código, não no Git). Se perder ou suspeitar de vazamento:
POST /v2/webhooks/:id/rotate-secret.
Eventos disponíveis
| Categoria | Eventos |
|---|---|
| Entrada (cash-in) | cashin.paid, cashin.refunded |
| Saída (cash-out) | cashout.success, cashout.failed, cashout.returned |
| Disputas | infraction.updated (substitui os antigos CHARGEBACK/BLOCKED) |
| Assinaturas | subscription.created, .activated, .paused, .canceled, .past_due, .expired |
Assine apenas o que você realmente processa. Cada evento a mais é ruído no seu endpoint.
O envelope V2
Todo webhook V2 chega no mesmo formato:
{
"event": "cashin.paid",
"environment": "TEST",
"payload": { "id": "...", "status": "APPROVED", "amount": 18990 }
}
Repare no campo environment: TEST ou LIVE. Use-o como trava de segurança — se o seu
ambiente de produção receber um evento TEST, algo está errado na configuração e você não deve
liberar o pedido.
Convivência com o V1: o modelo antigo (
postbackUrlpor transação, payload plano em camelCase) continua funcionando. Se você tiver os dois configurados, ambos disparam em paralelo — cuidado para não processar o mesmo pagamento duas vezes.
Validando a assinatura (obrigatório)
Um endpoint de webhook é uma URL pública. Sem validação, qualquer um pode enviar um POST dizendo que o pedido foi pago.
A Pagnovo assina com HMAC-SHA256 sobre o payload com as chaves ordenadas recursivamente:
import crypto from 'crypto';
function sortObjectKeys(obj: any): any {
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
if (obj !== null && typeof obj === 'object') {
return Object.keys(obj).sort().reduce((acc, key) => {
acc[key] = sortObjectKeys(obj[key]);
return acc;
}, {} as any);
}
return obj;
}
export function isValid(payload: unknown, received: string, secret: string) {
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(sortObjectKeys(payload)))
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Três detalhes que causam 90% dos "assinatura inválida":
- Reserialização diferente. Se o seu framework já parseou o JSON e você re-serializa com
outra ordem de chaves ou espaçamento, o hash muda. É exatamente por isso que existe o
sortObjectKeys— ele torna a ordem irrelevante. - Comparação com
===. Além de vazar tempo (permitindo ataque de timing), quebra se os tamanhos diferirem. UsetimingSafeEqualcom checagem de tamanho antes. - Secret errado. Confundir o secret de teste com o de produção é mais comum do que parece.
Idempotência: o mesmo evento pode chegar duas vezes
Reentrega é comportamento normal, não bug. Se sua aplicação demorou a responder, caiu no meio do processamento ou devolveu erro, o evento é reenviado.
O padrão seguro:
// 1. Valide a assinatura
if (!isValid(body.payload, signature, secret)) return res.status(401).end();
// 2. Trave por ID da transação (chave única no banco)
const inserted = await db.processedEvents.insertIfAbsent(body.payload.id);
if (!inserted) return res.status(200).end(); // já processado — ignore
// 3. Responda ANTES de processar
res.status(200).end();
// 4. Processe em fila
await queue.push({ event: body.event, payload: body.payload });
A ordem importa: responder 200 rápido e processar depois. Se você libera o pedido, envia e-mail e gera nota fiscal antes de responder, uma lentidão qualquer vira timeout — e o evento é reenviado, gerando trabalho duplicado.
Monitorando entregas
A API expõe dois endpoints que valem ouro em produção:
GET /v2/webhooks/:id/deliveries → histórico de entregas
GET /v2/webhooks/:id/metrics → métricas agregadas
Use-os para investigar "o cliente pagou e não recebeu". Antes de acusar a integração, verifique se a entrega saiu, qual status HTTP o seu servidor devolveu e quantas tentativas houve.
Há também POST /v2/webhooks/:id/test para disparar um evento de teste sem precisar de uma transação
real — ótimo para validar deploy novo.
Rede de segurança: nunca dependa só do webhook
Webhook é rápido, mas seu servidor pode estar fora do ar na hora exata. Mantenha um job de reconciliação que varre pedidos pendentes:
GET /transactions/:id
Esse endpoint aceita o ID da Pagnovo, o seu externalId ou o end2End do PIX. Rode a cada poucos
minutos para pedidos criados e ainda pendentes.
Guarde também o header x-trace-id de cada resposta nos seus logs. É com ele que o suporte
localiza a requisição exata nos servidores.
Testando antes de ir para produção
No sandbox (sk_test_*), os resultados são determinísticos por valor:
| Valor | Resultado |
|---|---|
| R$ 10,00 | Aprovado → cashin.paid |
| R$ 10,01 | Rejeitado |
| R$ 10,05 | Estorno → cashin.refunded |
| R$ 10,04 | Chargeback → infraction.updated |
Isso permite testar cada caminho de propósito, incluindo os ruins. Teste também: webhook duplicado,
webhook fora de ordem (um refunded chegando antes do paid) e assinatura inválida.
Checklist
- Secret em cofre, fora do código
- Assinatura HMAC validada com comparação em tempo constante
- Campo
environmentconferido - Idempotência por
idda transação - 200 imediato + processamento em fila
- Job de reconciliação com
GET /transactions/:id -
x-trace-idnos logs - Alerta se
deliveriesacusar falhas - Caminhos de rejeição, estorno e chargeback testados no sandbox
Veja a documentação de webhooks ou conheça nossa API PIX. Dúvidas na integração? Fale com o time.