FINORA Checkout · Guía
Verifica la firma del aviso
Cada aviso de FINORA llega con la cabecera X-Finora-Signature. Si tu servidor no la verifica, cualquiera que conozca tu
webhookUrl puede marcar pedidos como pagados. Esta guía deja la verificación funcionando.
La cabecera tiene tres partes separadas por coma:
X-Finora-Signature: t=1759590000,v1=5e0c…,v0=9a41…t: el momento del envío, en segundos desde 1970.v1:HMAC-SHA256(secreto, t + "." + cuerpo), en hexadecimal. Es la que verificas.v0: la firma del formato anterior, solo sobre el cuerpo. Se mantiene por compatibilidad; no la uses en código nuevo.
-
Lee el cuerpo crudo de la petición, tal como llegó, en bytes. No lo conviertas a objeto y de vuelta a texto: cualquier cambio de espacios u orden rompe la firma.
-
Separa
tyv1de la cabecera. Si falta alguno, rechaza con401. -
Compara
tcon tu reloj. Si la diferencia es mayor de 300 segundos, rechaza: es un aviso viejo o reenviado por alguien más. FINORA firma cada reintento con untnuevo, así que un reintento legítimo nunca falla por esto. -
Calcula
HMAC-SHA256con tu secreto sobre el textot+.+ cuerpo crudo y pásalo a hexadecimal. -
Compara tu resultado con
v1usando una comparación de tiempo constante. Si no coinciden, rechaza con401. -
Si
X-Finora-Entregaya la procesaste, responde200sin hacer nada más: es un reintento del mismo aviso. -
Procesa el aviso y responde
2xxen menos de 10 segundos. Si tu proceso es largo, encólalo y responde primero.
import crypto from 'node:crypto';
export function firmaValida(secreto, cuerpoCrudo, cabecera, ahora = 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);}
// Con Express, el cuerpo crudo se obtiene con express.raw en esta ruta.app.post('/webhooks/finora', express.raw({ type: 'application/json' }), manejarAviso);static boolean firmaValida(String secreto, byte[] cuerpoCrudo, String cabecera, long ahora) throws Exception { String t = null, v1 = null; for (String parte : cabecera.split(",")) { String[] kv = parte.trim().split("=", 2); if (kv.length != 2) continue; if (kv[0].equals("t")) t = kv[1]; if (kv[0].equals("v1")) v1 = kv[1]; } if (t == null || v1 == null) return false; long momento = Long.parseLong(t); if (Math.abs(ahora - momento) > 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((momento + ".").getBytes(StandardCharsets.UTF_8)); String esperada = HexFormat.of().formatHex(mac.doFinal(cuerpoCrudo)); return MessageDigest.isEqual(esperada.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));}<?php$cuerpo = file_get_contents('php://input');$partes = [];foreach (explode(',', $_SERVER['HTTP_X_FINORA_SIGNATURE'] ?? '') as $p) { [$k, $v] = array_pad(explode('=', trim($p), 2), 2, null); $partes[$k] = $v;}$t = (int) ($partes['t'] ?? 0);if ($t === 0 || empty($partes['v1']) || abs(time() - $t) > 300) { http_response_code(401); exit;}$esperada = hash_hmac('sha256', $t . '.' . $cuerpo, getenv('FINORA_SECRETO_WEBHOOK'));if (!hash_equals($esperada, $partes['v1'])) { http_response_code(401); exit;}$aviso = json_decode($cuerpo, true);http_response_code(200);Comprueba tu código con el vector de prueba
Sección titulada «Comprueba tu código con el vector de prueba»Antes de conectarte a FINORA, verifica que tu función calcula lo mismo que nosotros. Con estos datos, tu HMAC debe dar
exactamente este resultado (llama a tu función pasando ahora = 1700000000 para que la tolerancia no la rechace):
| Dato | Valor |
|---|---|
| Secreto | key |
| Cuerpo | The quick brown fox jumps over the lazy dog |
t | 1700000000 |
v1 esperado | 2f658d6aef4f246e91cd741bbcded7479e9605f9d41c9e248122a117e0e1765b |
v0 esperado (formato anterior) | f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 |
Desde una terminal puedes calcularlo con OpenSSL:
printf '%s' "1700000000.The quick brown fox jumps over the lazy dog" | openssl dgst -sha256 -hmac keyDespués, pide a FINORA un aviso de prueba real a tu endpoint. La respuesta incluye la firma y el cuerpo exactos que se enviaron:
curl -s -X POST "<URL_DEL_MERCHANT>/api/v1/checkout/webhooks/prueba" \ -H "X-API-Key: <TU_CLAVE_API>" -H "Content-Type: application/json" \ -d '{"url": "https://mitienda.example/webhooks/finora"}'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 | Olvidaste el t. delante del cuerpo | Firma t + "." + cuerpo, no solo el cuerpo |
| El vector pasa, los avisos reales fallan | Tu framework ya interpretó el JSON y firmas el texto regenerado | Lee el cuerpo crudo en esa ruta |
| Fallaba después de regenerar el secreto | Tu servidor sigue con el secreto anterior | Actualiza la variable de entorno; FINORA firma los reintentos con el secreto vigente |
| Falla de forma intermitente | El reloj de tu servidor está desfasado | Sincroniza la hora con NTP |