Webhooks de pago en la práctica: firma, idempotencia y reintentos
Cómo recibir notificaciones de pago con seguridad en la API Pagnovo — validación HMAC, idempotencia, respuesta rápida, reenvío y monitoreo de entregas.
Equipo Pagnovo · 2026-08-05
El webhook es el punto donde tu sistema descubre que el dinero entró. Si falla, el cliente paga y el pedido no se libera. Si es inseguro, alguien marca pedidos como pagados sin pagar. Vale la pena invertir los 30 minutos que toma esta guía.
Referencia completa: portal.pagnovo.com/docs.
Por qué no basta la respuesta de la API
Al crear un cobro PIX recibes status: "PENDING". El pago ocurre después, cuando el cliente abre
la app de su banco. No existe una respuesta síncrona que diga "pagado" — la confirmación llega de
forma asíncrona, por el webhook.
Diseñar el sistema asumiendo lo contrario es el error número uno al empezar.
Registrar el webhook
En Pagnovo registras el endpoint una vez y eliges qué eventos quieres recibir:
POST /v2/webhooks
{
"url": "https://tu-app.com/webhooks/pagnovo",
"description": "Producción",
"events": ["cashin.paid", "cashin.refunded", "cashout.success", "cashout.failed"]
}
La respuesta devuelve un secret en texto plano — una sola vez:
{ "id": "...", "secret": "..." }
Guárdalo en un gestor de secretos (no en el código, no en Git). Si lo pierdes o sospechas de una
filtración: POST /v2/webhooks/:id/rotate-secret.
Eventos disponibles
| Categoría | Eventos |
|---|---|
| Entrada (cash-in) | cashin.paid, cashin.refunded |
| Salida (cash-out) | cashout.success, cashout.failed, cashout.returned |
| Disputas | infraction.updated (reemplaza los antiguos CHARGEBACK/BLOCKED) |
| Suscripciones | subscription.created, .activated, .paused, .canceled, .past_due, .expired |
Suscríbete solo a lo que realmente procesas. Cada evento de más es ruido en tu endpoint.
El envelope V2
Todo webhook V2 llega con el mismo formato:
{
"event": "cashin.paid",
"environment": "TEST",
"payload": { "id": "...", "status": "APPROVED", "amount": 18990 }
}
Fíjate en el campo environment: TEST o LIVE. Úsalo como traba de seguridad — si tu entorno
de producción recibe un evento TEST, algo está mal configurado y no deberías liberar el pedido.
Convivencia con V1: el modelo antiguo (
postbackUrlpor transacción, payload plano en camelCase) sigue funcionando. Si tienes ambos configurados, disparan en paralelo — cuidado con procesar el mismo pago dos veces.
Validar la firma (obligatorio)
Un endpoint de webhook es una URL pública. Sin validación, cualquiera puede enviar un POST diciendo que el pedido fue pagado.
Pagnovo firma con HMAC-SHA256 sobre el payload con las claves 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);
}
Tres detalles que causan el 90% de los "firma inválida":
- Reserialización diferente. Si tu framework ya parseó el JSON y lo re-serializas con otro orden
de claves o espaciado, el hash cambia. Justamente por eso existe
sortObjectKeys— hace que el orden sea irrelevante. - Comparar con
===. Además de filtrar tiempo (permitiendo un ataque de timing), se rompe si los tamaños difieren. UsatimingSafeEqualcon verificación de longitud antes. - Secret equivocado. Confundir el de prueba con el de producción es más común de lo que parece.
Idempotencia: el mismo evento puede llegar dos veces
El reenvío es comportamiento normal, no un bug. Si tu aplicación tardó en responder, se cayó a mitad del procesamiento o devolvió error, el evento se reenvía.
El patrón seguro:
// 1. Valida la firma
if (!isValid(body.payload, signature, secret)) return res.status(401).end();
// 2. Bloquea por ID de transacción (clave única en la base)
const inserted = await db.processedEvents.insertIfAbsent(body.payload.id);
if (!inserted) return res.status(200).end(); // ya procesado — ignóralo
// 3. Responde ANTES de procesar
res.status(200).end();
// 4. Procesa en cola
await queue.push({ event: body.event, payload: body.payload });
El orden importa: responder 200 rápido y procesar después. Si liberas el pedido, envías email y generas factura antes de responder, cualquier lentitud se vuelve timeout — y el evento se reenvía, duplicando el trabajo.
Monitorear entregas
La API expone dos endpoints que valen oro en producción:
GET /v2/webhooks/:id/deliveries → historial de entregas
GET /v2/webhooks/:id/metrics → métricas agregadas
Úsalos para investigar "el cliente pagó y no recibió nada". Antes de culpar a la integración, revisa si la entrega salió, qué estado HTTP devolvió tu servidor y cuántos intentos hubo.
También existe POST /v2/webhooks/:id/test para disparar un evento de prueba sin una transacción
real — excelente para validar un deploy nuevo.
Red de seguridad: nunca dependas solo del webhook
El webhook es rápido, pero tu servidor puede estar caído en el momento exacto. Mantén un job de conciliación que recorra los pedidos pendientes:
GET /transactions/:id
Este endpoint acepta el ID de Pagnovo, tu externalId o el end2End del PIX. Ejecútalo cada pocos
minutos para pedidos creados y aún pendientes.
Guarda también el header x-trace-id de cada respuesta en tus logs. Es lo que el soporte usa
para ubicar la petición exacta en los servidores.
Probar antes de producción
En el sandbox (sk_test_*), los resultados son determinísticos por importe:
| Importe | Resultado |
|---|---|
| R$ 10,00 | Aprobado → cashin.paid |
| R$ 10,01 | Rechazado |
| R$ 10,05 | Reembolso → cashin.refunded |
| R$ 10,04 | Contracargo → infraction.updated |
Eso permite probar cada camino a propósito, incluidos los malos. Prueba también: webhook duplicado,
webhook fuera de orden (un refunded llegando antes del paid) y firma inválida.
Checklist
- Secret en un gestor de secretos, fuera del código
- Firma HMAC validada con comparación en tiempo constante
- Campo
environmentverificado - Idempotencia por
idde la transacción - 200 inmediato + procesamiento en cola
- Job de conciliación con
GET /transactions/:id -
x-trace-iden los logs - Alerta si
deliveriesmuestra fallos - Caminos de rechazo, reembolso y contracargo probados en el sandbox
Consulta la documentación de webhooks o conoce nuestra API PIX. ¿Dudas en la integración? Habla con el equipo.