Webhooks
Recibe notificaciones en tiempo real cuando ocurran eventos en tus órdenes de cobro.
¿Qué son los webhooks?
Los webhooks son notificaciones HTTP POST que NIIO envía a tu servidor cuando ocurre un evento importante, como cuando un pago se completa o falla. Esto te permite actualizar el estado de las órdenes en tu sistema de forma automática sin necesidad de consultar la API constantemente.
Tipos de eventos
| Evento | Descripción |
|---|---|
payment.completed |
El pago se completó exitosamente. La orden pasa a estado COMPLETED. |
payment.failed |
El pago falló. La orden pasa a estado FAILED. |
payment.expired |
El cobro venció sin que nadie lo pagara. |
payment.cancelled |
El comercio canceló el cobro antes de que se pagara. |
payment.confirmed |
El riel alcanzó la finalidad: ya puedes entregar. |
payment.refunded |
Se devolvió el importe completo. |
payment.partially_refunded |
Se devolvió una parte. El aviso trae refundedAmount y remainingAmount. |
payment.chargeback |
El pagador reclamó el cargo a su banco. |
payment.disputed |
El pago está en disputa. |
refund.requested |
El comercio pidió un reembolso. |
refund.approved |
Un operador aprobó la solicitud. Todavía no se ha devuelto el dinero. |
refund.rejected |
Un operador rechazó la solicitud. |
refund.completed |
Quedó constancia de la devolución. |
settlement.closed |
Se cerró la liquidación de un día (hora de Bogotá): lo cobrado, la comisión, el IVA, lo que llegó a la wallet y lo devuelto ese día. No mueve dinero: el cobro ya se había acreditado. |
Los eventos posteriores a payment.completed y payment.failed
se activan por cuenta: hoy solo llegan los dos primeros. Escribe a
developers@niio.app para habilitarlos.
Un cobro completado todavía puede cambiar. Un reembolso o un
contracargo llega como un evento NUEVO; el anterior nunca se edita. No des un
cobro por cerrado solo porque recibiste payment.completed: si
entregas mercancía, espera también payment.confirmed.
Estructura del webhook
Cada webhook incluye headers de seguridad y un payload JSON con los detalles del evento:
Headers
POST /tu-webhook-url HTTP/1.1
Host: tu-servidor.com
Content-Type: application/json
X-NIIO-Signature: sha256=a1b2c3d4e5f6...
X-NIIO-Timestamp: 1705323600000
| Header | Descripción |
|---|---|
X-NIIO-Signature |
Firma HMAC-SHA256 del payload para verificar autenticidad |
X-NIIO-Timestamp |
Timestamp en milisegundos de cuando se generó el webhook |
Payload
{
"event": "payment.completed",
"data": {
"id": "col_abc123def456",
"reference": "ORDER-001",
"amount": 50025,
"baseAmount": 50000,
"feeAmount": 25,
"currency": "COP",
"status": "COMPLETED",
"paidAt": "2024-01-15T12:15:00.000Z",
"metadata": {}
},
"timestamp": "2024-01-15T12:15:01.000Z"
}
baseAmount es el valor neto acreditado a tu comercio — el monto que pediste al crear la orden. amount es lo que pagó el pagador (baseAmount + feeAmount) y feeAmount el fee on-top que retiene NIIO. En órdenes anteriores a 2026 estos campos pueden venir null.
Payload de pago fallido
{
"event": "payment.failed",
"data": {
"id": "col_abc123def456",
"reference": "ORDER-001",
"amount": 50025,
"baseAmount": 50000,
"feeAmount": 25,
"currency": "COP",
"status": "FAILED",
"paidAt": null,
"metadata": {}
},
"timestamp": "2024-01-15T12:15:01.000Z"
}
Verificar la firma
La firma se calcula usando HMAC-SHA256 con tu webhook secret y la concatenación del timestamp y el payload:
signature = HMAC-SHA256(webhook_secret, timestamp + "." + payload)
Ejemplos de verificación
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, timestamp, webhookSecret) {
// Verificar que el timestamp no sea muy antiguo (5 minutos)
const currentTime = Date.now();
const webhookTime = parseInt(timestamp, 10);
if (currentTime - webhookTime > 5 * 60 * 1000) {
throw new Error('Webhook timestamp too old');
}
// Calcular la firma esperada
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
// Comparar firmas de forma segura
const receivedSig = signature.replace('sha256=', '');
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSig)
)) {
throw new Error('Invalid webhook signature');
}
return true;
}
// Uso en Express
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-niio-signature'];
const timestamp = req.headers['x-niio-timestamp'];
const payload = req.body.toString();
try {
verifyWebhookSignature(payload, signature, timestamp, process.env.WEBHOOK_SECRET);
const event = JSON.parse(payload);
// Procesar el evento
switch (event.event) {
case 'payment.completed':
// Marcar orden como pagada en tu sistema
await markOrderAsPaid(event.data.id, event.data.reference);
break;
case 'payment.failed':
// Manejar fallo - notificar al usuario, etc.
await handlePaymentFailure(event.data.id, event.data.reference);
break;
}
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error.message);
res.status(400).send('Invalid signature');
}
});
import hmac
import hashlib
import time
import json
from flask import Flask, request
app = Flask(__name__)
def verify_webhook_signature(payload, signature, timestamp, webhook_secret):
# Verificar que el timestamp no sea muy antiguo (5 minutos)
current_time = int(time.time() * 1000)
webhook_time = int(timestamp)
if current_time - webhook_time > 5 * 60 * 1000:
raise ValueError('Webhook timestamp too old')
# Calcular la firma esperada
signed_payload = f"{timestamp}.{payload}"
expected_signature = hmac.new(
webhook_secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Comparar firmas
received_sig = signature.replace('sha256=', '')
if not hmac.compare_digest(expected_signature, received_sig):
raise ValueError('Invalid webhook signature')
return True
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-NIIO-Signature')
timestamp = request.headers.get('X-NIIO-Timestamp')
payload = request.get_data(as_text=True)
try:
verify_webhook_signature(
payload,
signature,
timestamp,
os.environ['WEBHOOK_SECRET']
)
event = json.loads(payload)
# Procesar el evento
if event['event'] == 'payment.completed':
# Marcar orden como pagada en tu sistema
mark_order_as_paid(event['data']['id'], event['data']['reference'])
elif event['event'] == 'payment.failed':
# Manejar fallo - notificar al usuario, etc.
handle_payment_failure(event['data']['id'], event['data']['reference'])
return 'OK', 200
except ValueError as e:
return str(e), 400
function verifyWebhookSignature($payload, $signature, $timestamp, $webhookSecret) {
// Verificar que el timestamp no sea muy antiguo (5 minutos)
$currentTime = round(microtime(true) * 1000);
$webhookTime = intval($timestamp);
if ($currentTime - $webhookTime > 5 * 60 * 1000) {
throw new Exception('Webhook timestamp too old');
}
// Calcular la firma esperada
$signedPayload = $timestamp . '.' . $payload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret);
// Comparar firmas
$receivedSig = str_replace('sha256=', '', $signature);
if (!hash_equals($expectedSignature, $receivedSig)) {
throw new Exception('Invalid webhook signature');
}
return true;
}
// Uso
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_NIIO_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_NIIO_TIMESTAMP'] ?? '';
try {
verifyWebhookSignature($payload, $signature, $timestamp, getenv('WEBHOOK_SECRET'));
$event = json_decode($payload, true);
// Procesar el evento
switch ($event['event']) {
case 'payment.completed':
// Marcar orden como pagada en tu sistema
markOrderAsPaid($event['data']['id'], $event['data']['reference']);
break;
case 'payment.failed':
// Manejar fallo - notificar al usuario, etc.
handlePaymentFailure($event['data']['id'], $event['data']['reference']);
break;
}
http_response_code(200);
echo 'OK';
} catch (Exception $e) {
http_response_code(400);
echo $e->getMessage();
}
Política de reintentos
Si tu endpoint no responde con un código 2xx, NIIO reintentará el webhook siguiendo este esquema:
| Intento | Tiempo después del fallo |
|---|---|
| 1 | Inmediato |
| 2 | 5 minutos |
| 3 | 30 minutos |
| 4 | 2 horas |
| 5 | 24 horas |
Después de 5 intentos fallidos, el webhook se marca como fallido y no se reintenta más. Puedes ver el historial de webhooks en el dashboard.
Buenas prácticas
- Responde rápido: Responde con 200 OK tan pronto verifiques la firma. Procesa la lógica de negocio de forma asíncrona.
- Idempotencia: Tu endpoint debe manejar el mismo evento múltiples veces sin efectos secundarios.
- Verifica siempre: Nunca proceses un webhook sin verificar la firma.
- Usa HTTPS: Tu endpoint webhook debe usar HTTPS para proteger los datos en tránsito.
- Logging: Registra todos los webhooks recibidos para debugging y auditoría.
Regenerar el webhook secret
Si sospechas que tu webhook secret ha sido comprometido, puedes regenerarlo desde el dashboard:
- Ve a Dashboard → Configuración → Webhooks
- Haz clic en "Regenerar Secret"
- Actualiza tu servidor con el nuevo secret