Appearance
Webhooks
Un webhook es Finova avisándote a ti. Sin ellos, la única forma de enterarte de que a tu cliente le cobraron es preguntar: un GET /incomes cada pocos minutos que casi siempre devuelve lo mismo, y que de todas formas llega tarde.
Registras una URL, eliges a qué eventos se suscribe, y Finova te manda una petición firmada cada vez que pasa uno de ellos.
Desde la aplicación, sin escribir código
Si no quieres tocar la API para esto, Ajustes → Finova for Developers → Webhooks hace lo mismo desde la pantalla: registrar la URL, elegir los eventos (agrupados y en español), rotar el secreto y ver el historial de entregas de cada uno.
Es la vía recomendada para dar de alta el endpoint una vez. La API de abajo existe para lo que sí conviene automatizar: un alta por cliente, un despliegue que reconfigura, un script de migración.
En un minuto
bash
curl -X POST https://developers.fi-nova.com/api/v1/webhook-endpoints \
-H 'Authorization: Bearer finova_sk_TU_SECRETO' \
-H 'Content-Type: application/json' \
-d '{
"data": {
"url": "https://tu-servidor.com/webhooks/finova",
"enabled_events": ["sale.paid", "recurring.occurrence_created", "invoice.*"],
"description": "Portal de clientes"
}
}'json
{
"data": {
"id": "65f0a1f1234567890abcdef0",
"object": "webhook_endpoint",
"url": "https://tu-servidor.com/webhooks/finova",
"enabled_events": ["sale.paid", "recurring.occurrence_created", "invoice.*"],
"active": true,
"secret": "whsec_4f3c...",
"secret_last_four": "a91c"
}
}El secreto sale una sola vez
secret aparece únicamente en la respuesta que crea el endpoint (y en la de rotate-secret). No se puede volver a leer. Guárdalo donde guardes tus credenciales antes de cerrar la terminal; si lo pierdes, rota y actualiza.
El sobre
Todos los avisos tienen la misma forma. Lo que cambia es data.
json
{
"id": "evt_9c1f4a2b7e0d3f6a8b5c2d1e4f7a0b3c",
"evento": "sale.paid",
"event": "sale.paid",
"created_at": "2026-09-17T18:04:11Z",
"api_version": "2026-09-17",
"data": {
"object": "sale",
"id": "65c2a1f1234567890abcdef0",
"status": "paid",
"amount_cents": 1500000,
"currency": "MXN",
"occurred_at": "2026-09-17T18:04:10Z",
"metadata": { "orderId": "ord_123", "clientId": "portal-clientes" }
}
}| Campo | Qué es |
|---|---|
id | Id estable del evento. Es lo que te permite descartar un reenvío. |
evento / event | El mismo valor, con los dos nombres. Usa el que te sea cómodo. |
created_at | Cuándo ocurrió el hecho, no cuándo se intentó entregar. |
api_version | Versión de la API con la que se construyó el cuerpo. |
data | El recurso. Siempre trae object, id y metadata. |
El aviso lleva el hecho, no el recurso entero
data trae lo justo para que puedas actuar y el id para pedir el resto. No mandamos el recurso completo a propósito: entre que el evento se encola y se entrega pueden pasar horas de reintentos, y un cuerpo completo llegaría desfasado sin que tuvieras forma de saberlo. El id, en cambio, nunca caduca.
metadata es lo que hace que el aviso se explique solo
Si cuando creaste la venta (o el ingreso, o la recurrencia) mandaste metadata, vuelve dentro de cada aviso sobre ella. Es la diferencia entre recibir un id de Finova que no sabes a qué corresponde y recibir tu propio orderId.
Y resuelve el otro problema, el que no se ve hasta que está en producción: por tu endpoint también van a pasar avisos sobre ventas que alguien registró a mano dentro de Finova, que no tienen nada que ver contigo. Un aviso sin tu metadata es, por definición, uno que no te toca — descártalo sin pensar, en lugar de salir a adivinar por importe.
Ver Ingresos → metadata.
Comprobar la firma
Cada petición lleva la cabecera X-Finova-Signature, con el mismo formato que Stripe:
X-Finova-Signature: t=1758132251,v1=6a9f3c...Lo que se firma es "{t}.{cuerpo en crudo}" con HMAC-SHA256 y el secreto del endpoint.
Firma sobre el cuerpo CRUDO
No sobre el objeto ya parseado y vuelto a serializar. JSON.stringify(req.body) no devuelve byte por byte lo que se firmó —cambia el orden de las claves, los espacios y el escapado— y la firma falla de formas que parecen magia negra.
La marca de tiempo va dentro de lo firmado, y eso es deliberado: una firma sobre el cuerpo a secas sería válida para siempre, así que quien capture una petición podría reenviarla mañana. Rechaza lo que venga con más de 5 minutos de desfase, en valor absoluto —un reloj adelantado es tan sospechoso como uno atrasado.
js
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCIA_SEG = 300
export function verificarFirma(crudo, cabecera, secreto) {
if (!cabecera) return false
const partes = new Map(
cabecera.split(',').map((t) => t.trim().split('=')).filter((p) => p.length === 2),
)
const t = partes.get('t')
const v1 = partes.get('v1')
if (!t || !v1 || !/^\d+$/.test(t)) return false
const ahora = Math.floor(Date.now() / 1000)
if (Math.abs(ahora - Number(t)) > TOLERANCIA_SEG) return false
const esperada = createHmac('sha256', secreto).update(`${t}.${crudo}`, 'utf8').digest()
const recibida = Buffer.from(v1, 'hex')
if (recibida.length !== esperada.length) return false
return timingSafeEqual(recibida, esperada)
}ruby
require "openssl"
TOLERANCIA_SEG = 300
def firma_valida?(crudo, cabecera, secreto)
return false if cabecera.blank?
partes = cabecera.split(",").map { |t| t.strip.split("=", 2) }.to_h
t = partes["t"]
v1 = partes["v1"]
return false if t.blank? || v1.blank? || t !~ /\A\d+\z/
return false if (Time.now.to_i - t.to_i).abs > TOLERANCIA_SEG
esperada = OpenSSL::HMAC.hexdigest("SHA256", secreto, "#{t}.#{crudo}")
ActiveSupport::SecurityUtils.secure_compare(esperada, v1)
endpython
import hmac, hashlib, time
TOLERANCIA_SEG = 300
def firma_valida(crudo: bytes, cabecera: str, secreto: str) -> bool:
if not cabecera:
return False
partes = dict(p.strip().split("=", 1) for p in cabecera.split(",") if "=" in p)
t, v1 = partes.get("t"), partes.get("v1")
if not t or not v1 or not t.isdigit():
return False
if abs(int(time.time()) - int(t)) > TOLERANCIA_SEG:
return False
esperada = hmac.new(
secreto.encode(), f"{t}.".encode() + crudo, hashlib.sha256
).hexdigest()
return hmac.compare_digest(esperada, v1)Compara siempre en tiempo constante (timingSafeEqual, secure_compare, compare_digest). Un == normal filtra, byte a byte, cuánto acertó quien lo intenta.
Cabeceras
| Cabecera | Qué trae |
|---|---|
X-Finova-Signature | t=<segundos>,v1=<hmac hex> |
X-Finova-Event | El nombre del evento, para poder enrutar sin parsear el cuerpo. |
X-Finova-Event-Id | El mismo id del cuerpo. |
X-Finova-Delivery | Id de este intento concreto. Útil para cruzar con tus logs. |
X-Finova-Attempt | Número de intento, empezando en 1. |
Reintentos
Cualquier respuesta 2xx cuenta como entregada. Todo lo demás se reintenta — también los 4xx: un 404 puede ser un despliegue a medias, y un 401, un secreto que acabas de rotar en tu extremo.
Seis intentos, con espera creciente:
| Intento | Cuándo |
|---|---|
| 1 | inmediato |
| 2 | +1 minuto |
| 3 | +5 minutos |
| 4 | +30 minutos |
| 5 | +2 horas |
| 6 | +6 horas |
Ocho horas en total: suficiente para cubrir el reinicio de un servidor o un despliegue largo sin convertirse en una tormenta.
Contesta rápido y haz el trabajo después. El corte de lectura es de 10 segundos. Acepta el aviso, encólalo y devuelve 200; si haces el trabajo dentro de la petición, un pico tuyo se convierte en un reintento nuestro.
Apagado automático
Tras 20 fallos seguidos, Finova desactiva el endpoint y deja de intentarlo. Lo verás en active: false con disabled_at y disabled_reason. Reactivarlo (PUT con active: true) pone el contador a cero.
Idempotencia: vas a recibir el mismo evento dos veces
Antes o después pasa: la entrega llega, tu servidor la procesa, y la respuesta se pierde por el camino. Para nosotros es un fallo y reintentamos; para ti es un duplicado.
Por eso cada aviso trae un id estable, el mismo en todos los intentos. Guárdalo y descarta lo que ya hayas visto.
js
const primeraVez = await registrarEventoSiEsNuevo(aviso.id) // índice único
if (!primeraVez) return res.sendStatus(200)Que la clave sea única en tu base de datos y no un if en memoria: dos entregas simultáneas pasarían el if las dos.
Los eventos
Dinero
| Evento | Cuándo |
|---|---|
income.created | Se registró un ingreso, cobrado o a crédito. |
income.paid | Entró dinero. Se emite por cada cobro, incluidos los parciales. |
income.deleted | Se eliminó un ingreso (revierte sus efectos). |
sale.created | Se registró una venta. |
sale.paid | La venta quedó saldada. Se emite una sola vez, en la transición. |
sale.refunded | Se aplicó una devolución. |
income.paid y sale.paid no son sinónimos — suscríbete a UNO
income.paid es cada cobro; sale.paid es la venta completa liquidada. En una venta a tres parcialidades recibirías tres income.paid y un sale.paid, y los importes se solapan. Elige según lo que necesites: los abonos parciales (income.paid) o el «ya está pagado del todo» (sale.paid). Suscribirte a los dos y tratarlos igual cuenta el dinero dos veces.
Recurrencias
| Evento | Cuándo |
|---|---|
recurring.created | Se dio de alta una transacción recurrente. |
recurring.occurrence_created | Se generó el periodo. El que evita que tengas tu propio reloj. |
recurring.ended | Se agotó (ends_on, ends_count o fin de la periodicidad). |
recurring.deactivated | Alguien la apagó (active: false). |
recurring.occurrence_created trae recurring_occurrence_on con la fecha de la ocurrencia, no una etiqueta de periodo. La traducción a 2026-10 la haces tú con la periodicidad de tu contrato: no queremos que Finova conozca tu convención.
Ver Transacciones recurrentes.
Facturación (CFDI)
| Evento | Cuándo |
|---|---|
invoice.emitted | Se timbró el CFDI de una venta. |
invoice.cancelled | Se canceló ante el SAT. |
invoice.substituted | Se emitió una sustitución. Trae substituted_uuid. |
invoice.payment_complement_added | Se timbró un complemento de pago (PPD). |
Ver Facturas.
CRM
| Evento | Cuándo |
|---|---|
customer.created | Alta de cliente. |
lead.created | Alta de lead. |
Comodines
invoice.* cubre los cuatro eventos de factura; * cubre todos.
El comodín sólo se admite al final y sobre un segmento entero: invoice.* sí, inv* no. La razón es que un * en medio parece hacer algo distinto de lo que hace, y quien lo escribe se entera cuando le falta un aviso en producción.
Administrar endpoints
GET /api/v1/webhook-endpoints | Listar. El meta trae available_events. |
GET /api/v1/webhook-endpoints/:id | Ver uno. |
POST /api/v1/webhook-endpoints | Crear. Devuelve el secreto. |
PUT /api/v1/webhook-endpoints/:id | Cambiar URL, eventos o activarlo/desactivarlo. |
DELETE /api/v1/webhook-endpoints/:id | Borrar. |
POST /api/v1/webhook-endpoints/:id/rotate-secret | Rotar. Devuelve el nuevo secreto. |
GET /api/v1/webhook-endpoints/:id/deliveries | Historial de entregas. |
Alcances: read-webhook_endpoints, create-webhook_endpoints, update-webhook_endpoints, delete-webhook_endpoints.
Los mismos endpoints existen dentro de la aplicación bajo /api/v1/user/developers/webhook-endpoints, autenticados con la sesión y el permiso manage-developers en vez de con una API key. Es lo que usa la pantalla de Ajustes: registrar un webhook no debería obligarte a generar antes una credencial de máquina.
Salud del endpoint
Cada endpoint publica cómo le está yendo, para que puedas ponerle una alarma sin depender de que alguien mire un panel:
| Campo | Qué mirar |
|---|---|
consecutive_failures | Subiendo es la señal temprana. |
last_delivery_at / last_delivery_status | El último intento. |
disabled_at / disabled_reason | Con valor: Finova ya dejó de intentarlo. |
«No me llegó el aviso»
GET /webhook-endpoints/:id/deliveries contesta esa pregunta: si el evento se emitió, qué contestó tu servidor y cuándo toca el siguiente intento. Acepta ?event= y ?status= (pending, succeeded, failed, exhausted).
json
{
"data": [
{
"id": "65f0b2e1234567890abcdef1",
"object": "webhook_delivery",
"event": "sale.paid",
"event_id": "evt_9c1f4a2b7e0d3f6a8b5c2d1e4f7a0b3c",
"status": "pending",
"attempts": 2,
"max_attempts": 6,
"next_attempt_at": "2026-09-17T18:11:00Z",
"response_status": 502,
"error_message": null
}
]
}Sobre GET de una entrega concreta se incluye además el payload completo —el cuerpo exacto que se firmó— y response_body.
Las entregas se guardan 30 días. Pasado eso, lo que te protege de duplicar no es nuestro registro sino tu idempotencia, que no caduca.
Rotar un secreto
bash
curl -X POST https://developers.fi-nova.com/api/v1/webhook-endpoints/65f0.../rotate-secret \
-H 'Authorization: Bearer finova_sk_TU_SECRETO'Existe para poder responder a una fuga sin perder la suscripción: borrar el endpoint y crear otro te obligaría a volver a elegir los eventos y dejaría un hueco en el que no llega nada.
El cambio es inmediato: la siguiente entrega ya va firmada con el nuevo. Si eso te deja un momento sin cobertura, acepta las dos firmas durante el despliegue.
HTTPS
En producción la URL tiene que ser https. El cuerpo lleva importes y los ids de tus clientes; sobre HTTP plano viaja en claro.