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.

Configura tu webhook URL
Ve a Dashboard → Configuración → Webhooks para configurar la URL donde recibirás las notificaciones.

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"
}
¿Cuánto entró neto a tu cuenta?
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

¡Siempre verifica la firma!
Antes de procesar cualquier webhook, verifica que la firma sea válida para asegurarte de que proviene de NIIO y no ha sido alterado.

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:

  1. Ve a Dashboard → Configuración → Webhooks
  2. Haz clic en "Regenerar Secret"
  3. Actualiza tu servidor con el nuevo secret
Actualiza tu servidor primero
Al regenerar el secret, los webhooks con la firma anterior serán rechazados. Asegúrate de actualizar tu servidor con el nuevo secret antes de regenerarlo, o hazlo durante un período de bajo tráfico.