Ir al contenido
FINORA Academy
SistemaFINORA Core

FINORA Core · Guía

Recibir y verificar los webhooks del core

ImplementadoImplementado

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.

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):

Ventana de terminal
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.

  1. Lee el cuerpo crudo de la petición, en bytes, tal como llegó. No lo conviertas a objeto y de vuelta a texto.

  2. Separa t y v1 de X-Finora-Signature. Si falta alguno, responde 401.

  3. Si t se aleja de tu reloj más de 300 segundos, responde 401. El core firma cada intento con un t nuevo: un reintento legítimo nunca falla por esto.

  4. Calcula HMAC-SHA256(secreto, t + "." + cuerpo) en hexadecimal y compáralo con v1 en tiempo constante. Si no coinciden, 401.

  5. Busca X-Finora-Webhook-Id en tu registro. Si ya lo procesaste, responde 200 sin hacer nada más: es un reintento.

  6. Encola el aviso, responde 2xx y 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.

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):

DatoValor
Secretokey
CuerpoThe quick brown fox jumps over the lazy dog
t1700000000
v1 esperado2f658d6aef4f246e91cd741bbcded7479e9605f9d41c9e248122a117e0e1765b
Ventana de terminal
printf '%s' "1700000000.The quick brown fox jumps over the lazy dog" | openssl dgst -sha256 -hmac key

Por API, POST /v1/webhooks/deliveries:search (alcance webhooks:read) con filtros opcionales:

Ventana de terminal
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.

  1. Lee httpStatus y lastError. Un 4xx es casi siempre tu receptor: firma mal verificada, ruta equivocada, autenticación de tu lado. Un fallo de red o 5xx repetido, tu receptor estuvo caído.

  2. Corrige la causa en tu receptor.

  3. 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 mismo X-Finora-Webhook-Id, así que tu deduplicación sigue funcionando.

  4. Si el circuito de la suscripción está abierto (circuitState: OPEN y pausedUntil en 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.

SíntomaCausa más comúnArreglo
Falla siempre, también con el vectorFirmas solo el cuerpoFirma t + "." + cuerpo
El vector pasa y los avisos reales fallanTu framework ya interpretó el JSONLee el cuerpo crudo en esa ruta
Empezó a fallar de golpeAlguien rotó el secreto: el anterior deja de firmar de inmediatoCarga el secreto nuevo
Falla de forma intermitenteTu reloj está desfasadoSincroniza la hora con NTP

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.