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 (postbackUrl por 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":

  1. 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.
  2. Comparação com ===. Além de vazar tempo (permitindo ataque de timing), quebra se os tamanhos diferirem. Use timingSafeEqual com checagem de tamanho antes.
  3. 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


Veja a documentação de webhooks ou conheça nossa API PIX. Dúvidas na integração? Fale com o time.