Skip to content

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" }
  }
}
CampoQué es
idId estable del evento. Es lo que te permite descartar un reenvío.
evento / eventEl mismo valor, con los dos nombres. Usa el que te sea cómodo.
created_atCuándo ocurrió el hecho, no cuándo se intentó entregar.
api_versionVersión de la API con la que se construyó el cuerpo.
dataEl 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)
end
python
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 ​

CabeceraQué trae
X-Finova-Signaturet=<segundos>,v1=<hmac hex>
X-Finova-EventEl nombre del evento, para poder enrutar sin parsear el cuerpo.
X-Finova-Event-IdEl mismo id del cuerpo.
X-Finova-DeliveryId de este intento concreto. Útil para cruzar con tus logs.
X-Finova-AttemptNú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:

IntentoCuándo
1inmediato
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 ​

EventoCuándo
income.createdSe registró un ingreso, cobrado o a crédito.
income.paidEntró dinero. Se emite por cada cobro, incluidos los parciales.
income.deletedSe eliminó un ingreso (revierte sus efectos).
sale.createdSe registró una venta.
sale.paidLa venta quedó saldada. Se emite una sola vez, en la transición.
sale.refundedSe 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 ​

EventoCuándo
recurring.createdSe dio de alta una transacción recurrente.
recurring.occurrence_createdSe generó el periodo. El que evita que tengas tu propio reloj.
recurring.endedSe agotó (ends_on, ends_count o fin de la periodicidad).
recurring.deactivatedAlguien 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) ​

EventoCuándo
invoice.emittedSe timbró el CFDI de una venta.
invoice.cancelledSe canceló ante el SAT.
invoice.substitutedSe emitió una sustitución. Trae substituted_uuid.
invoice.payment_complement_addedSe timbró un complemento de pago (PPD).

Ver Facturas.

CRM ​

EventoCuándo
customer.createdAlta de cliente.
lead.createdAlta 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-endpointsListar. El meta trae available_events.
GET /api/v1/webhook-endpoints/:idVer uno.
POST /api/v1/webhook-endpointsCrear. Devuelve el secreto.
PUT /api/v1/webhook-endpoints/:idCambiar URL, eventos o activarlo/desactivarlo.
DELETE /api/v1/webhook-endpoints/:idBorrar.
POST /api/v1/webhook-endpoints/:id/rotate-secretRotar. Devuelve el nuevo secreto.
GET /api/v1/webhook-endpoints/:id/deliveriesHistorial 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:

CampoQué mirar
consecutive_failuresSubiendo es la señal temprana.
last_delivery_at / last_delivery_statusEl último intento.
disabled_at / disabled_reasonCon 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.

Hecho con cuidado por Finova.