Eventos y webhooks
El contrato de eventos está publicado para diseño; la entrega de eventos todavía no está en servicio.
El CMS publica eventos cuando algo cambia: una compra autorizada, una recarga, un bloqueo, una decisión sobre una solicitud. Puedes recibirlos de dos formas:
| Modo | Cómo funciona | Cuándo usarlo |
|---|---|---|
WEBHOOK | FINORA hace POST a tu URL https con cada evento, firmado | Quieres reaccionar en segundos y puedes exponer un endpoint |
PULL | Tú pides los eventos desde tu último cursor confirmado | No puedes recibir conexiones entrantes, o necesitas orden estricto |
Las suscripciones las administras con el alcance webhooks:write; la lectura de eventos exige events:read.
Tipos de evento
| Tipo | Origen | Opt-in |
|---|---|---|
operacion.autorizada, operacion.rechazada | Compra, avance o preautorización con fase final | no |
operacion.reversada, operacion.devuelta | Reverso o devolución | no |
cuenta.abonada | Recarga, pago o reembolso | no |
cuenta.ajustada | Ajuste crédito o débito | no |
ajuste.decidido | Decisión o vencimiento de un ajuste pendiente | no |
cuenta.ajuste_indexacion | Revaluación UVC por cuenta | sí |
indexacion.revaluacion_completada | Corrida de revaluación por lote, una por corrida y producto | sí |
cuenta.interes_devengado, cuenta.comision_cobrada | Devengo o comisión | no |
tarjeta.bloqueada, tarjeta.desbloqueada, tarjeta.reportada | Acciones sobre el plástico | no |
tarjeta.limites_cambiados | Topes del plástico | no |
cuenta.linea_cambiada | Cambio de línea aplicado | no |
solicitud_linea.decidida | Decisión sobre una solicitud de línea | no |
solicitud_principal.decidida | Decisión o anulación de un cambio de cuenta principal | no |
lote_limites.completado | Fin de un lote | no |
estado_cuenta.emitido | Estado de cuenta emitido | no |
Puedes usar comodines de sufijo (operacion.*). Suscribirte a un tipo opt-in (por su nombre o con indexacion.*) exige además movements:adjustments; sin él, 403 ALCANCE_INSUFICIENTE. Pide los tipos opt-in por su nombre. Ver Movimientos y ajustes.
Crear una suscripción WEBHOOK
curl https://api-cms.example.com/v1/subscriptions \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"nombre": "Cobranza",
"tipo": "WEBHOOK",
"tiposEvento": ["operacion.*", "cuenta.abonada"],
"url": "https://cliente.example.com/finora/webhooks"
}'
{
"subscriptionId": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
"nombre": "Cobranza",
"tipo": "WEBHOOK",
"tiposEvento": ["operacion.*", "cuenta.abonada"],
"url": "https://cliente.example.com/finora/webhooks",
"estado": "ACTIVA",
"cursorConfirmado": null,
"fallosConsecutivos": 0,
"creadaEn": "2026-10-20T12:00:00-04:00",
"secreto": {
"secretId": "whsec_2026_10",
"valor": "whsec_EJEMPLO_NO_REAL_c2VjcmV0by1kZS1lamVtcGxv",
"vigenteDesde": "2026-10-20T12:00:00-04:00"
}
}
- La URL debe ser
httpsy de un dominio de tu lista registrada; si no, 422URL_NO_PERMITIDA. Las entregas salen por un proxy que bloquea rangos privados y no sigue redirecciones. - El secreto se muestra una sola vez (
Cache-Control: no-store). Guárdalo en tu bóveda.
Qué recibe tu endpoint
POST /finora/webhooks HTTP/1.1
Content-Type: application/json
webhook-id: e4a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b
webhook-timestamp: 1792519511
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "e4a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b",
"seq": "c2VxOjEwMzA",
"tipo": "tarjeta.limites_cambiados",
"ocurrio": "2026-10-20T14:05:11-04:00",
"productCode": "210",
"datos": {
"cardId": "9b1f0e2d-3c4b-4a59-8e7f-6a5b4c3d2e10",
"accountId": null,
"operationId": null,
"jobId": null,
"requestId": null,
"detalle": { "maximoDiario": "500.00", "origen": "API" }
}
}
Los eventos son avisos con identificadores: si necesitas el estado completo, consúltalo con el GET correspondiente.
Verificar la firma
La firma sigue Standard Webhooks: webhook-signature lleva una o más firmas v1,<base64> separadas por espacio, cada una un HMAC-SHA256 de webhook-id.webhook-timestamp.cuerpo con un secreto vigente. Durante una rotación llegan dos firmas: acepta el evento si cualquiera coincide.
import crypto from 'node:crypto';
/**
* secretos: los valores vigentes («whsec_» + llave en base64, como define Standard Webhooks);
* cuerpo: los bytes crudos recibidos.
*/
export function verificar({ cabeceras, cuerpo, secretos }) {
const id = cabeceras['webhook-id'];
const ts = Number(cabeceras['webhook-timestamp']);
if (!id || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; // ±5 min
const firmadas = String(cabeceras['webhook-signature'] ?? '')
.split(' ')
.filter((f) => f.startsWith('v1,'))
.map((f) => Buffer.from(f.slice(3), 'base64'));
return secretos.some((s) => {
const llave = Buffer.from(s.replace(/^whsec_/, ''), 'base64');
const esperada = crypto.createHmac('sha256', llave).update(`${id}.${ts}.`).update(cuerpo).digest();
return firmadas.some((f) => f.length === esperada.length && crypto.timingSafeEqual(f, esperada));
});
}
Reglas para tu receptor:
- Verifica la firma sobre el cuerpo crudo, antes de parsear el JSON.
- Rechaza un
webhook-timestampcon más de 5 minutos de desfase. - Deduplica por
webhook-id: la entrega es al menos una vez y el mismo evento puede llegar varias veces. - Responde cualquier 2xx rápido (acuse) y procesa en segundo plano.
- No asumas orden: cada evento lleva
seq; si necesitas orden estricto, usa una suscripciónPULL.
Reintentos, desactivación y replay
GET /v1/subscriptions/{subscriptionId}/deliverieslista las entregas fallidas o en reintento.POST /v1/subscriptions/{subscriptionId}/replayreenvía un rango de hasta 90 días atrás (422REPLAY_FUERA_DE_VENTANAsi es más antiguo). Responde 202 con unreplayId:
curl https://api-cms.example.com/v1/subscriptions/7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918/replay \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"desde":"2026-10-18T00:00:00Z","hasta":"2026-10-19T00:00:00Z"}'
POST /v1/subscriptions/{subscriptionId}/secrets:rotatecrea un secreto nuevo (visible una vez). El anterior sigue vigente 24 horas y, mientras tanto, cada entrega lleva las dos firmas.DELETE /v1/subscriptions/{subscriptionId}desactiva la suscripción.
Suscripción PULL
Crea la suscripción con "tipo": "PULL" (sin url) y lee en orden desde tu último cursor confirmado:
curl https://api-cms.example.com/v1/subscriptions/7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918/events \
-H "Authorization: Bearer $TOKEN"
Cuando hayas procesado y guardado una página, confirma su cursor; la siguiente lectura empezará después de él:
curl https://api-cms.example.com/v1/subscriptions/7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918/cursors \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"cursor":"c2VxOjEwNTA"}'
- Confirmar un cursor por delante de lo entregado responde 422
CURSOR_ADELANTADO. - Si dos procesos confirman a la vez, uno recibe 409
CURSOR_CONCURRENTE: relee y continúa. - Para una lectura puntual sin suscripción existe
GET /v1/events(alcanceevents:read).