Ir al contenido

Configurar webhooks

Cuando ocurre un evento en una transacción, Rivopay envía un POST a tu webhookUrl registrada con los datos del evento.

En la plataforma de cliente podrá configurar su propio webhookUrl en donde recibir los webhooks.

HeaderEjemploDescripción
Content-Typeapplication/jsonSiempre JSON.
X-Webhook-Eventpayin.completedTipo de evento.
X-Webhook-Timestamp2026-07-22T15:04:05.123ZISO-8601 UTC con milisegundos. El del primer envío, no el de cada reintento. Parte del string firmado.
X-Webhook-Signaturesha256=9f86d0...Firma HMAC-SHA256 en hex, con prefijo sha256=.
User-AgentPaymentGateway-Webhook/1.0Identificador del emisor.
EventoHeader X-Webhook-EventCuándo se dispara
payin.completedpayin.completedCobro recibido y confirmado
payin.failedpayin.failedCobro rechazado o fallido
payin.reversedpayin.reversedCobro recibido fue devuelto al pagador (Nequi)
payout.completedpayout.completedDispersión enviada y confirmada
payout.failedpayout.failedDispersión rechazada por el proveedor
payout.reversedpayout.reversedDispersión fue revertida (Nequi)
payin.expiredpayin.expiredEl cobro venció sin pago (QR/push expirado)
payout.expiredpayout.expiredLa transferencia venció sin confirmación del proveedor

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.

string_to_sign = `${X-Webhook-Timestamp}\n${raw_body}`
firma = HMAC_SHA256(clave, string_to_sign) // hex minúsculas
X-Webhook-Signature = "sha256=" + firma
  • raw_body = el cuerpo HTTP crudo, byte a byte, tal como llega. No re-serializar el JSON antes de firmar (un JSON.parse + JSON.stringify puede 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.
  1. Leer el cuerpo crudo del request (no el parseado).
  2. Recomputar expected = "sha256=" + HMAC_SHA256(clave, timestamp + "\n" + raw_body).
  3. Comparar expected con X-Webhook-Signature en tiempo constante (timingSafeEqual / hash_equals). No usar ==.
  4. Validar la ventana temporal: rechazar si |now − X-Webhook-Timestamp| > 5 min (anti-replay).
  5. (Recomendado) Deduplicar por txId: los reintentos reenvían el mismo payload, firma y timestamp.
  6. Responder 2xx si se acepta. Cualquier no-2xx (o timeout) dispara reintentos.

La Clave se configura por región y proveedorregions.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.
Node.js (Express, raw body)
const crypto = require('crypto');
// Importante: capturar el body CRUDO
app.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);
});
PHP
$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);
Python (Flask)
import hmac, hashlib, os
from datetime import datetime, timezone
from 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 ...
  • Hacer hex_decode de 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 de timingSafeEqual / hash_equals.
  • Olvidar el \n entre timestamp y body.
  • No validar la ventana temporal → vulnerable a replay.
  • Usar la Clave de otro proveedor cuando se comparte una sola URL.

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 });
});