Configurar webhooks
¿Qué son los webhooks?
Sección titulada «¿Qué son los webhooks?»Cuando ocurre un evento en una transacción, Rivopay envía un POST a tu webhookUrl registrada con los datos del evento.
Configurar tu webhookUrl
Sección titulada «Configurar tu webhookUrl»En la plataforma de cliente podrá configurar su propio webhookUrl en donde recibir los webhooks.
Headers del webhook
Sección titulada «Headers del webhook»| Header | Ejemplo | Descripción |
|---|---|---|
Content-Type | application/json | Siempre JSON. |
X-Webhook-Event | payin.completed | Tipo de evento. |
X-Webhook-Timestamp | 2026-07-22T15:04:05.123Z | ISO-8601 UTC con milisegundos. El del primer envío, no el de cada reintento. Parte del string firmado. |
X-Webhook-Signature | sha256=9f86d0... | Firma HMAC-SHA256 en hex, con prefijo sha256=. |
User-Agent | PaymentGateway-Webhook/1.0 | Identificador del emisor. |
Eventos disponibles
Sección titulada «Eventos disponibles»| Evento | Header X-Webhook-Event | Cuándo se dispara |
|---|---|---|
payin.completed | payin.completed | Cobro recibido y confirmado |
payin.failed | payin.failed | Cobro rechazado o fallido |
payin.reversed | payin.reversed | Cobro recibido fue devuelto al pagador (Nequi) |
payout.completed | payout.completed | Dispersión enviada y confirmada |
payout.failed | payout.failed | Dispersión rechazada por el proveedor |
payout.reversed | payout.reversed | Dispersión fue revertida (Nequi) |
payin.expired | payin.expired | El cobro venció sin pago (QR/push expirado) |
payout.expired | payout.expired | La transferencia venció sin confirmación del proveedor |
Verificación de firma (HMAC-SHA256)
Sección titulada «Verificación de firma (HMAC-SHA256)»Cada webhook que Rivopay envía va firmado con HMAC-SHA256 usando la Clave que registraste. Recalcula la firma con tu misma Clave para confirmar que el webhook viene de Rivopay y que el cuerpo no fue alterado.
Formato de la Clave
Sección titulada «Formato de la Clave»Cómo se calcula la firma
Sección titulada «Cómo se calcula la firma»string_to_sign = `${X-Webhook-Timestamp}\n${raw_body}`firma = HMAC_SHA256(clave, string_to_sign) // hex minúsculasX-Webhook-Signature = "sha256=" + firmaraw_body= el cuerpo HTTP crudo, byte a byte, tal como llega. No re-serializar el JSON antes de firmar (unJSON.parse+JSON.stringifypuede cambiar orden/espacios y romper la firma).- Separador = un salto de línea
\n(LF,0x0A) entre timestamp y body. - La firma es hex en minúsculas.
Pasos de verificación (obligatorios)
Sección titulada «Pasos de verificación (obligatorios)»- Leer el cuerpo crudo del request (no el parseado).
- Recomputar
expected = "sha256=" + HMAC_SHA256(clave, timestamp + "\n" + raw_body). - Comparar
expectedconX-Webhook-Signatureen tiempo constante (timingSafeEqual/hash_equals). No usar==. - Validar la ventana temporal: rechazar si
|now − X-Webhook-Timestamp| > 5 min(anti-replay). - (Recomendado) Deduplicar por
txId: los reintentos reenvían el mismo payload, firma y timestamp. - Responder
2xxsi se acepta. Cualquier no-2xx(o timeout) dispara reintentos.
Qué Clave usar (por proveedor)
Sección titulada «Qué Clave usar (por proveedor)»La Clave se configura por región y proveedor — regions.CO.webhookSecrets.NEQUI y regions.CO.webhookSecrets.BREB, cada una independiente. Se solicitan al equipo de Rivopay durante el onboarding. El payload no incluye el proveedor, así que:
- Recomendado: configurar una URL de webhook por proveedor (
webhookUrls). Cada endpoint recibe solo su proveedor → usa su Clave. - Si usas una sola URL para todos los proveedores, registra la misma Clave para todos.
Ejemplos
Sección titulada «Ejemplos»const crypto = require('crypto');// Importante: capturar el body CRUDOapp.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => { const secret = process.env.RIVOPAY_WEBHOOK_SECRET; // la Clave tal cual, SIN hex_decode const signature = req.header('X-Webhook-Signature') || ''; const timestamp = req.header('X-Webhook-Timestamp') || ''; const rawBody = req.body; // Buffer crudo
// 1. Ventana temporal (±5 min) if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60 * 1000) { return res.status(401).send('stale timestamp'); } // 2. Recomputar firma const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(`${timestamp}\n${rawBody.toString('utf8')}`) .digest('hex'); // 3. Comparación en tiempo constante const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(rawBody.toString('utf8')); // ... procesar (idempotente por event.txId) ... res.sendStatus(200);});$secret = getenv('RIVOPAY_WEBHOOK_SECRET'); // la Clave tal cual, SIN hex2bin()$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';$rawBody = file_get_contents('php://input');
if (abs(time() - strtotime($timestamp)) > 300) { http_response_code(401); exit('stale timestamp');}$expected = 'sha256=' . hash_hmac('sha256', $timestamp . "\n" . $rawBody, $secret);if (!hash_equals($expected, $signature)) { http_response_code(401); exit('bad signature');}$event = json_decode($rawBody, true);// ... procesar idempotente por $event['txId'] ...http_response_code(200);import hmac, hashlib, osfrom datetime import datetime, timezonefrom flask import request, abort
def verify(): secret = os.environ['RIVOPAY_WEBHOOK_SECRET'].encode() # .encode(), NO bytes.fromhex() signature = request.headers.get('X-Webhook-Signature', '') timestamp = request.headers.get('X-Webhook-Timestamp', '') raw_body = request.get_data() # bytes crudos
ts = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) if abs((datetime.now(timezone.utc) - ts).total_seconds()) > 300: abort(401)
msg = timestamp.encode() + b'\n' + raw_body expected = 'sha256=' + hmac.new(secret, msg, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): abort(401) # ... procesar idempotente por txId ...Errores comunes
Sección titulada «Errores comunes»- Hacer
hex_decodede la Clave antes de firmar → firma no coincide. Va tal cual, como cadena UTF-8. - Firmar el body re-serializado en vez del crudo → firma no coincide. Usa los bytes tal cual llegan.
- Comparar con
==en vez detimingSafeEqual/hash_equals. - Olvidar el
\nentre timestamp y body. - No validar la ventana temporal → vulnerable a replay.
- Usar la Clave de otro proveedor cuando se comparte una sola URL.
Ejemplo de endpoint
Sección titulada «Ejemplo de endpoint»El ejemplo mínimo de abajo usa express.json() por simplicidad. En producción, verifica la firma con el body crudo como se muestra arriba antes de confiar en el payload.
app.post('/webhook/rivopay', express.json(), async (req, res) => { const eventType = req.headers['x-webhook-event']; const payload = req.body; const { txId, status } = payload;
switch (eventType) { case 'payin.completed': await confirmarPedido(txId, payload.netAmount); break; case 'payin.failed': await cancelarPedido(txId); break; case 'payout.failed': await notificarFalloPago(txId, payload.failureReason); break; case 'payout.reversed': await procesarReversion(txId); break; }
// SIEMPRE responde 200 aunque tengas un error interno res.status(200).json({ received: true });});