Webhooks - Guía Profunda
Implementación avanzada de webhooks para entornos de producción.
Resumen de Webhooks
Los webhooks son notificaciones HTTP POST que Koywe envía a tu servidor cuando ocurren eventos (orden completada, pago recibido, etc.).
¿Por qué usar Webhooks?
Recibe notificaciones instantáneas de eventos
No depende de polling o verificaciones manuales
Reduce llamadas API innecesarias
Maneja miles de eventos sin sobrecarga
Configurar Webhooks
Endpoint de Webhook
Tu endpoint debe:
- Ser públicamente accesible (HTTPS requerido en producción)
- Responder rápidamente (< 5 segundos)
- Devolver código de estado 200-299 para éxito
- Manejar reintentos idempotentemente
Implementación Básica
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANTE: Usar express.raw para verificación de firma
app.post('/webhooks/koywe',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
// 1. Verificar firma
const signature = req.headers['koywe-signature'];
const secret = process.env.KOYWE_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, secret)) {
console.error('Firma de webhook inválida');
return res.status(401).send('Firma inválida');
}
// 2. Parsear evento
const event = JSON.parse(req.body.toString());
// 3. Procesar evento
await processWebhookEvent(event);
// 4. Responder rápido
res.status(200).send('OK');
} catch (error) {
console.error('Error procesando webhook:', error);
res.status(500).send('Error interno');
}
}
);
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return signature === expectedSignature;
}
async function processWebhookEvent(event) {
console.log('Evento recibido:', event.type);
switch (event.type) {
case 'order.completed':
await handleOrderCompleted(event.data);
break;
case 'order.failed':
await handleOrderFailed(event.data);
break;
// ... otros tipos de evento
}
}from flask import Flask, request
import hmac
import hashlib
import json
app = Flask(__name__)
@app.route('/webhooks/koywe', methods=['POST'])
def webhook():
try:
# 1. Verificar firma
signature = request.headers.get('koywe-signature')
secret = os.getenv('KOYWE_WEBHOOK_SECRET')
if not verify_webhook_signature(request.data, signature, secret):
return 'Firma inválida', 401
# 2. Parsear evento
event = json.loads(request.data)
# 3. Procesar evento
process_webhook_event(event)
# 4. Responder rápido
return 'OK', 200
except Exception as e:
print(f"Error procesando webhook: {e}")
return 'Error interno', 500
def verify_webhook_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return signature == expected_signatureTipos de Eventos
Koywe emite eventos en varias familias de recursos. Todos los eventos comparten la misma envoltura superior (id, type, version, occurred_at, source, environment, organization_id, merchant_id, data) que se muestra en la siguiente sección — pero el contenido de data varía según la familia del evento. Los campos como orderId y amountIn del ejemplo siguiente son específicos de la familia order.*; no aplican a merchant.*, policy.*, invitation.*, bank_income.* ni webhook.ping, que llevan su propia estructura de data específica del recurso. La taxonomía es aditiva — pueden introducirse nuevos eventos con el tiempo, y cualquier cambio incompatible se señala en el campo version de la envoltura.
Ciclo de Vida de la Orden
| Tipo de Evento | Cuándo se Dispara |
|---|---|
order.created | La orden fue creada |
order.approved | La aprobación de política se resolvió; la orden pasa a PROCESSING |
order.processing | El pago o transferencia está siendo procesado |
order.paid | Pago confirmado (por el proveedor de pagos o confirmación en cadena) |
order.completed | Fondos liquidados — acreditados en PAYIN/ONRAMP, debitados en PAYOUT/OFFRAMP |
order.failed | La orden falló en alguna etapa (pago, ledger, transferencia) |
order.expired | La orden pasó su fecha de vencimiento sin ser pagada |
order.canceled | La orden fue cancelada por el usuario o el sistema |
order.refunded | La orden fue reembolsada tras haber sido completada |
order.updated | Fallback genérico para cambios de estado no cubiertos arriba |
Ortografía: La forma canónica es order.canceled (una sola l). Borradores internos previos usaron order.cancelled — si tienes handlers con esa ortografía, cámbialos a order.canceled.
Merchant y KYB
| Tipo de Evento | Cuándo se Dispara |
|---|---|
merchant.created | Se creó un merchant dentro de una organización |
merchant.kyb.in_progress | Se inició la revisión de KYB del merchant |
merchant.kyb.approved | KYB aprobado; el merchant puede operar en producción |
merchant.kyb.rejected | KYB rechazado; se requiere remediación |
Invitaciones
Se emiten tanto para invitaciones a nivel de usuario como a nivel de organización (se distinguen por el resourceType del payload: invitation vs organization_invitation).
| Tipo de Evento | Cuándo se Dispara |
|---|---|
invitation.created | Se emitió una invitación |
invitation.accepted | El destinatario aceptó la invitación |
invitation.expired | La invitación expiró sin ser aceptada |
invitation.failed | La entrega o procesamiento de la invitación falló |
invitation.assigned | El destino de la invitación fue resuelto/asignado a un usuario |
Aprobaciones de Políticas
Se emiten cuando una operación protegida atraviesa el flujo de aprobación de políticas.
| Tipo de Evento | Cuándo se Dispara |
|---|---|
policy.approval.requested | Se disparó una política; se notificó a uno o más aprobadores |
policy.approval.received | Un aprobador individual respondió (aprobar/rechazar) |
policy.approval.approved | Se alcanzó el umbral; la solicitud queda aprobada |
policy.approval.rejected | La política rechazó la solicitud |
Ejecución de Políticas
Un evento dinámico aparte se dispara cuando una política finalmente se ejecuta sobre su recurso objetivo. El tipo de evento incrusta el tipo de recurso:
policy.{resourceType}.executedTipos de recurso actualmente emitidos:
| Tipo de Evento | Recurso |
|---|---|
policy.order.executed | Orden |
policy.account.executed | Cuenta bancaria |
policy.deal.executed | Deal |
policy.user_invite.executed | Invitación de usuario |
policy.passkey_enrollment.executed | Enrolamiento de passkey |
policy.wallet_access_policy.executed | Política de acceso a wallet |
policy.policy.executed | Definición de política |
policy.policy_rule.executed | Regla de política |
Si quieres manejar todas las ejecuciones de política de forma genérica, haz match contra el patrón policy.*.executed en lugar de enumerar cada tipo de recurso — se suman nuevos a medida que más recursos quedan protegidos por política.
Ingresos Bancarios
| Tipo de Evento | Cuándo se Dispara |
|---|---|
bank_income.received | Se acreditó un depósito en una cuenta bancaria virtual — se dispara tanto para transferencias reales como para simulaciones en sandbox |
Sistema
| Tipo de Evento | Cuándo se Dispara |
|---|---|
webhook.ping | Evento de prueba emitido por el endpoint ping del webhook; úsalo para verificar conectividad y validación de firma |
Estructura de Payload
{
"id": "evt_abc123",
"type": "order.completed",
"version": "v1",
"occurred_at": "2025-11-13T15:30:00Z",
"source": "koywe.api",
"environment": "production",
"organization_id": "org_xyz",
"merchant_id": "mrc_abc",
"data": {
"orderId": "ord_123456",
"type": "PAYIN",
"status": "COMPLETED",
"amountIn": 50000,
"amountOut": 50000,
"originCurrencySymbol": "COP",
"destinationCurrencySymbol": "COP",
"externalId": "order-12345",
"createdAt": "2025-11-13T15:00:00Z",
"completedAt": "2025-11-13T15:30:00Z"
}
}Política de Reintentos
Koywe envía cada webhook una sola vez, y reintenta únicamente si la falla parece transitoria. Las reglas son:
- Timeout de la solicitud: 30 segundos. Respuestas más lentas se tratan como falla de red.
- Fallas reintentables — la entrega se reintenta con backoff:
- Respuestas
5xxdesde tu endpoint 429 Too Many Requests- Errores de red y timeouts (sin respuesta HTTP)
- Respuestas
- Fallas no reintentables — la entrega queda marcada como
FAILEDtras un único intento, sin reintentos:- Cualquier otro
4xx(p. ej.400,401,403,404,422)
- Cualquier otro
Las fallas reintentables se reencolan con backoff. Cada intento incrementa retryCount y se agrega a attempts[] en la entrega; cuando se agota el presupuesto de reintentos, deliveryStatus pasa a FAILED — esto aplica también a 429, por lo que devolver 429 señala throttling pero no exime a la entrega de quedar marcada como FAILED si se agotan los reintentos. Puedes inspeccionar el estado con GET /api/v1/organizations/:organizationId/webhook-events/:eventId/deliveries, y reenviar manualmente un evento fallido con el endpoint de replay.
Los reintentos solo aplican antes del ACK. La lógica de reintentos automáticos de Koywe (5xx / 429 / timeout de red) solo se activa cuando recibe una respuesta de error — o ninguna respuesta — desde tu endpoint. Una vez que devuelves 200 OK, la entrega se considera exitosa y no se reintenta, aunque tu procesamiento en segundo plano falle después. Si usas el patrón de ACK inmediato (ver Rendimiento más abajo), debes persistir de forma durable el evento (por ej. encolar en una cola de trabajos confiable o hacer commit a una base de datos) antes de devolver 200 OK; de lo contrario, una caída entre el ACK y el procesamiento hará que pierdas el evento y Koywe no lo reenviará automáticamente — tendrás que invocar el endpoint de replay manualmente.
4xx es terminal. Si tu endpoint devuelve 401 porque todavía no aplicaste una rotación de secreto, o 404 por un typo en la ruta, Koywe no reintenta — asume que la solicitud está fundamentalmente mal. Arregla el endpoint y usa POST /api/v1/organizations/:organizationId/webhook-events/:eventId/replay para reenviar. Devuelve 5xx si quieres que Koywe reintente automáticamente (por ej. ante caídas transitorias aguas abajo). Devuelve 429 para señalar throttling — los reintentos siguen aplicando con backoff, y la entrega puede igualmente quedar marcada como FAILED una vez agotado el presupuesto de reintentos.
Idempotencia Crítica: Tu endpoint debe manejar el mismo evento múltiples veces de forma segura.
Implementar Idempotencia
const redis = require('redis');
const client = redis.createClient();
async function processWebhookEvent(event) {
const eventId = event.id;
// Verificar si ya procesamos este evento
const alreadyProcessed = await client.get(`webhook:${eventId}`);
if (alreadyProcessed) {
console.log('Evento ya procesado, saltando');
return;
}
// Procesar evento
await handleEvent(event);
// Marcar como procesado (expirar después de 7 días)
await client.setEx(`webhook:${eventId}`, 604800, 'processed');
}async function processWebhookEvent(event) {
const eventId = event.id;
// Insertar con constraint único
try {
await db.query(
'INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW())',
[eventId]
);
} catch (error) {
if (error.code === '23505') { // Violación de constraint único
console.log('Evento ya procesado');
return;
}
throw error;
}
// Procesar evento
await handleEvent(event);
}Mejores Prácticas
Seguridad
Siempre haz esto:
- ✅ Verificar firma de webhook
- ✅ Usar HTTPS en producción
- ✅ Validar estructura de payload
- ✅ Implementar límite de tasa
- ✅ Registrar todos los webhooks recibidos
Rendimiento
Procesamiento Asíncrono: Responde rápido (200 OK) y procesa eventos en background:
const Bull = require('bull');
const webhookQueue = new Bull('webhooks');
// Endpoint de webhook - responde rápido
app.post('/webhooks/koywe', express.raw({type: 'application/json'}), async (req, res) => {
// Convertir body raw a string una vez
const rawBody = req.body.toString();
if (!verifySignature(rawBody, req.headers['koywe-signature'])) {
return res.status(401).send('Inválido');
}
// Parsear JSON desde string
const event = JSON.parse(rawBody);
// Agregar a cola y responder inmediatamente
await webhookQueue.add(event);
res.status(200).send('OK');
});
// Procesador de trabajos - maneja eventos en background
webhookQueue.process(async (job) => {
const event = job.data;
await processWebhookEvent(event);
});Monitoreo
async function logWebhookEvent(event, status, error = null) {
await db.query(`
INSERT INTO webhook_log (
event_id, event_type, status, error, received_at
) VALUES ($1, $2, $3, $4, NOW())
`, [event.id, event.type, status, error]);
}
app.post('/webhooks/koywe', async (req, res) => {
const event = JSON.parse(req.body);
try {
await processWebhookEvent(event);
await logWebhookEvent(event, 'success');
res.status(200).send('OK');
} catch (error) {
await logWebhookEvent(event, 'error', error.message);
res.status(500).send('Error');
}
});Solución de Problemas
Webhooks No Recibidos
Verificar:
- Endpoint es públicamente accesible
- Firewall permite tráfico entrante
- SSL/TLS válido (en producción)
- Endpoint responde < 5 segundos
- Devuelve código 200-299
Webhooks Duplicados
const processedEvents = new Set();
async function processWebhookEvent(event) {
// Método en memoria simple (usar DB/Redis en producción)
if (processedEvents.has(event.id)) {
console.log('Evento duplicado, saltando');
return;
}
processedEvents.add(event.id);
await handleEvent(event);
}