FINORA Core · How-to guide
Recibir y verificar los webhooks del core
This content is not available in your language yet.
Esta guía supone que ya leíste Webhooks firmados del core. Al terminar, tu servidor recibe los avisos del core, descarta los falsos y los repetidos, y sabes qué hacer cuando una entrega falla.
1. Da de alta la suscripción
Sección titulada «1. Da de alta la suscripción»Tienes dos caminos. Elige uno.
Con una credencial que tenga webhooks:write (y un token en $TOKEN, ver
Tu primera llamada a la API del core):
curl -s -i "$GW/v1/webhooks" -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{ "name": "conciliador-erp", "description": "Asientos contables hacia el ERP del Banco Demo", "url": "https://erp.banco-demo.example/finora/webhooks", "eventTypes": ["AccEntryPosted", "TransactionPosted"], "headers": { "Authorization": "Bearer <TOKEN_DE_TU_RECEPTOR>" } }'Verás 201 con la suscripción y, esta única vez, el campo secret (empieza por whsec_). La respuesta lleva
Cache-Control: no-store. Copia el secreto al almacén de secretos de tu receptor ahora: no se vuelve a mostrar.
Los valores de tus cabeceras propias tampoco se devuelven nunca: un GET /v1/webhooks/{id} solo trae sus nombres en
headerNames.
- Abre Administración → Webhooks.
- Pulsa Crear y llena el nombre, la URL (solo HTTPS, sin usuario ni clave en la URL), los tipos de evento
separados por coma (
AccEntryPosted, TransactionPosted, AccountChanged, CustomerChanged, o*para todos) y, si tu receptor lo pide, las cabeceras propias como objeto JSON. - Guarda. Para ver un secreto, pulsa Rotar secreto: el core genera uno nuevo y lo muestra en el diálogo «Secreto de firma del webhook». Cópialo y entrégalo al receptor; no se volverá a mostrar.
2. Verifica la firma en tu receptor
Sección titulada «2. Verifica la firma en tu receptor»-
Lee el cuerpo crudo de la petición, en bytes, tal como llegó. No lo conviertas a objeto y de vuelta a texto.
-
Separa
tyv1deX-Finora-Signature. Si falta alguno, responde401. -
Si
tse aleja de tu reloj más de 300 segundos, responde401. El core firma cada intento con untnuevo: un reintento legítimo nunca falla por esto. -
Calcula
HMAC-SHA256(secreto, t + "." + cuerpo)en hexadecimal y compáralo conv1en tiempo constante. Si no coinciden,401. -
Busca
X-Finora-Webhook-Iden tu registro. Si ya lo procesaste, responde200sin hacer nada más: es un reintento. -
Encola el aviso, responde
2xxy procésalo después. El core espera la respuesta hasta 10 segundos.
import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.util.HexFormat;
public final class FirmaFinora {
public static boolean valida(String secreto, byte[] cuerpoCrudo, String cabecera, long ahora) throws Exception { if (cabecera == null) return false; Long t = null; String v1 = null; for (String parte : cabecera.split(",")) { String p = parte.trim(); if (p.startsWith("t=")) t = Long.parseLong(p.substring(2)); else if (p.startsWith("v1=")) v1 = p.substring(3); } if (t == null || v1 == null || Math.abs(ahora - t) > 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((t + ".").getBytes(StandardCharsets.UTF_8)); byte[] esperada = HexFormat.of().formatHex(mac.doFinal(cuerpoCrudo)).getBytes(StandardCharsets.US_ASCII); return MessageDigest.isEqual(esperada, v1.getBytes(StandardCharsets.US_ASCII)); }}En Spring, recibe el cuerpo como @RequestBody byte[] cuerpo en esa ruta para no perder los bytes originales, y pasa
Instant.now().getEpochSecond() como ahora.
import crypto from 'node:crypto';import express from 'express';
export function firmaValida(secreto, cuerpoCrudo, cabecera, ahora = Math.floor(Date.now() / 1000)) { const partes = Object.fromEntries((cabecera ?? '').split(',').map((p) => p.trim().split('='))); const t = Number(partes.t); if (!Number.isFinite(t) || !partes.v1) return false; if (Math.abs(ahora - t) > 300) return false; const esperada = crypto.createHmac('sha256', secreto).update(`${t}.`).update(cuerpoCrudo).digest('hex'); const a = Buffer.from(partes.v1); const b = Buffer.from(esperada); return a.length === b.length && crypto.timingSafeEqual(a, b);}
const app = express();const vistos = new Set(); // en producción, una tabla con índice único
app.post('/finora/webhooks', express.raw({ type: 'application/json' }), (req, res) => { if (!firmaValida(process.env.FINORA_WEBHOOK_SECRETO, req.body, req.get('X-Finora-Signature'))) { return res.sendStatus(401); } const id = req.get('X-Finora-Webhook-Id'); if (vistos.has(id)) return res.sendStatus(200); vistos.add(id); encolar(JSON.parse(req.body.toString('utf8'))); res.sendStatus(202);});Comprueba tu código con un vector de prueba
Sección titulada «Comprueba tu código con un vector de prueba»Con estos datos tu función debe calcular exactamente este v1 (pasa ahora = 1700000000 para que la tolerancia no lo
rechace):
| Dato | Valor |
|---|---|
| Secreto | key |
| Cuerpo | The quick brown fox jumps over the lazy dog |
t | 1700000000 |
v1 esperado | 2f658d6aef4f246e91cd741bbcded7479e9605f9d41c9e248122a117e0e1765b |
printf '%s' "1700000000.The quick brown fox jumps over the lazy dog" | openssl dgst -sha256 -hmac key3. Sigue tus entregas
Sección titulada «3. Sigue tus entregas»Por API, POST /v1/webhooks/deliveries:search (alcance webhooks:read) con filtros opcionales:
curl -s -D - "$GW/v1/webhooks/deliveries:search?limit=50&paginationDetails=ON" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{ "subscriptionId": "<ID_DE_TU_SUSCRIPCION>", "status": "DEAD", "from": "2026-09-19T00:00:00Z" }'Cada entrega trae id, eventId, eventType, cursor, webhookId (el valor de X-Finora-Webhook-Id), attempt,
status (PENDING, DELIVERED, FAILED o DEAD), httpStatus, nextAttemptAt, lastAttemptAt, deliveredAt y
lastError. El cuerpo enviado no se devuelve.
En la consola, Administración → Entregas de webhook muestra lo mismo.
4. Si una entrega quedó DEAD
Sección titulada «4. Si una entrega quedó DEAD»-
Lee
httpStatusylastError. Un4xxes casi siempre tu receptor: firma mal verificada, ruta equivocada, autenticación de tu lado. Un fallo de red o5xxrepetido, tu receptor estuvo caído. -
Corrige la causa en tu receptor.
-
Reenvía la entrega. Por API, con una clave de idempotencia:
Ventana de terminal curl -s -X POST "$GW/v1/webhooks/deliveries/<ID_DE_LA_ENTREGA>:resend" \-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)"Responde
202. Si la entrega ya estaba pendiente,409 CORE_WEBHOOK_DELIVERY_PENDING. En la consola: selecciona la entrega en Entregas de webhook y pulsa Reenviar. El reenvío conserva el mismoX-Finora-Webhook-Id, así que tu deduplicación sigue funcionando. -
Si el circuito de la suscripción está abierto (
circuitState: OPENypausedUntilen la suscripción), puedes esperar a que pase la pausa o cerrarlo ya:POST /v1/webhooks/{id}:reset-circuit, o en Administración → Webhooks, selecciona la suscripción y pulsa Cerrar circuito. Las entregas pendientes salen en el próximo ciclo.
Si la firma no coincide
Sección titulada «Si la firma no coincide»| Síntoma | Causa más común | Arreglo |
|---|---|---|
| Falla siempre, también con el vector | Firmas solo el cuerpo | Firma t + "." + cuerpo |
| El vector pasa y los avisos reales fallan | Tu framework ya interpretó el JSON | Lee el cuerpo crudo en esa ruta |
| Empezó a fallar de golpe | Alguien rotó el secreto: el anterior deja de firmar de inmediato | Carga el secreto nuevo |
| Falla de forma intermitente | Tu reloj está desfasado | Sincroniza la hora con NTP |
Cambiar la suscripción
Sección titulada «Cambiar la suscripción»PUT /v1/webhooks/{id} es un reemplazo completo (manda todos los campos) y es idempotente, sin Idempotency-Key.
Con "rotateSecret": true devuelve un secreto nuevo, una vez. DELETE /v1/webhooks/{id} la elimina. Los nombres son
únicos: uno repetido responde 409 CORE_WEBHOOK_NAME_TAKEN.