Saltar al contenido principal

Webhooks

Al cerrar una sesión de cobro (aprobada, rechazada, en duda, cancelada o expirada) FINORA hace POST a la webhookUrl que indicaste al crearla. Es un aviso firmado: la fuente de verdad para marcar el pedido como pagado.

Qué llega​

POST /finora/webhook HTTP/1.1
Content-Type: application/json
User-Agent: FINORA-Checkout-Webhook/1.0
X-Finora-Evento: checkout.sesion
X-Finora-Entrega: 4812
X-Finora-Signature: t=1757775912,v1=8f3c…,v0=2a71…

{"evento":"checkout.sesion","sesionId":"CSAgkpH6tSLSO-58tneFeBaw","estado":"APROBADA",
"referencia":"PED-1001","monto":1250.00,"moneda":"VES","riel":"PAGO_MOVIL","codigo":"00",
"mensaje":"Aprobada","autorizacion":{"estado":"APROBADO","codigo":"00","mensaje":"Aprobada",
"referenciaBanco":"BK-FNR1K2X9-1","autorizador":"BANCO_SIM","intencion":"FNR1K2X9…"},
"recibo":{"referencia":"FNR1K2X9…","referenciaComercio":"PED-1001","monto":1250.00,"moneda":"VES",
"comercio":"tu comercio","riel":"PAGO_MOVIL","referenciaBanco":"BK-FNR1K2X9-1",
"referenciaPagador":"04141234567","fecha":"2026-09-13T15:05:12.000+00:00"},
"sandbox":false,"ocurridoEn":"2026-09-13T15:05:12.341Z"}

recibo es null si la sesión no quedó APROBADA. El evento de prueba (checkout.prueba) trae {evento, merchantCode, mensaje, ocurridoEn} y la misma firma.

La firma​

X-Finora-Signature trae tres partes separadas por coma:

ParteQué es
tEpoch en segundos del envío.
v1hex(HMAC-SHA256(secreto, t + "." + cuerpo)). Verifica esta.
v0hex(HMAC-SHA256(secreto, cuerpo)), el formato anterior sin timestamp; se mantiene una versión.

Reglas:

  1. Calcula sobre el cuerpo crudo (los bytes que llegaron), nunca sobre el JSON reparseado.
  2. Compara en tiempo constante.
  3. Rechaza si |ahora − t| > 300 s. Cada reintento se firma con un t fresco, así que un reenvío legítimo a los 25 minutos llega con su propio t.
  4. El secreto es por comercio. Se genera con la primera sesión y lo regeneras en Webhooks: el anterior deja de firmar al instante.

Vector de prueba: secreto key, cuerpo The quick brown fox jumps over the lazy dog, t=1700000000 → v1 = 2f658d6aef4f246e91cd741bbcded7479e9605f9d41c9e248122a117e0e1765b, v0 = f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8. Si tu v1 no coincide, estás firmando el JSON reparseado u olvidaste el t. delante.

Verificar en tu lenguaje​

Node.js
import crypto from "node:crypto";
import express from "express";

const TOLERANCIA_S = 300;

function firmaValida(secreto, cuerpo, cabecera) {
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(Date.now() / 1000 - t) > TOLERANCIA_S) return false; // reenvío tardío
const esperada = crypto.createHmac("sha256", secreto).update(`${t}.`).update(cuerpo).digest("hex");
const a = Buffer.from(partes.v1), b = Buffer.from(esperada);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
app.post("/finora/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!firmaValida(process.env.FINORA_WEBHOOK_SECRET, req.body, req.get("X-Finora-Signature"))) return res.sendStatus(401);
if (yaProcesada(req.get("X-Finora-Entrega"))) return res.sendStatus(200); // idempotencia
const evento = JSON.parse(req.body.toString("utf8"));
if (evento.evento === "checkout.sesion") pedidos.marcar(evento.referencia, evento.estado, evento.codigo, evento.recibo); // el último manda
res.sendStatus(200);
});
Java
static final long TOLERANCIA_S = 300;

static boolean firmaValida(String secreto, byte[] cuerpo, String cabecera) throws Exception {
String t = null, v1 = null;
for (String parte : cabecera.split(",")) {
String[] kv = parte.trim().split("=", 2);
if (kv.length == 2 && kv[0].equals("t")) t = kv[1];
if (kv.length == 2 && kv[0].equals("v1")) v1 = kv[1];
}
if (t == null || v1 == null) return false;
long epoch = Long.parseLong(t);
if (Math.abs(System.currentTimeMillis() / 1000 - epoch) > TOLERANCIA_S) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((epoch + ".").getBytes(StandardCharsets.UTF_8));
String esperada = HexFormat.of().formatHex(mac.doFinal(cuerpo));
return MessageDigest.isEqual(esperada.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
}

// Spring: recibe el cuerpo crudo, no un @RequestBody deserializado
@PostMapping(value = "/finora/webhook", consumes = "application/json")
ResponseEntity<Void> webhook(@RequestBody byte[] cuerpo, @RequestHeader("X-Finora-Signature") String firma, @RequestHeader("X-Finora-Entrega") String entrega) throws Exception {
if (!firmaValida(secreto, cuerpo, firma)) return ResponseEntity.status(401).build();
// deduplicar por `entrega`, procesar y responder en menos de 10 s
return ResponseEntity.ok().build();
}
C#
static bool FirmaValida(string secreto, byte[] cuerpo, string cabecera)
{
var partes = cabecera.Split(',').Select(p => p.Trim().Split('=', 2)).Where(kv => kv.Length == 2).ToDictionary(kv => kv[0], kv => kv[1]);
if (!partes.TryGetValue("t", out var t) || !partes.TryGetValue("v1", out var v1)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secreto));
var esperada = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(t + ".").Concat(cuerpo).ToArray())).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(esperada), Encoding.UTF8.GetBytes(v1));
}

// ASP.NET: leer el cuerpo crudo
app.MapPost("/finora/webhook", async (HttpRequest req) => {
using var ms = new MemoryStream(); await req.Body.CopyToAsync(ms);
if (!FirmaValida(secreto, ms.ToArray(), req.Headers["X-Finora-Signature"]!)) return Results.Unauthorized();
return Results.Ok();
});
Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

TOLERANCIA_S = 300

def firma_valida(secreto: str, cuerpo: bytes, cabecera: str) -> bool:
partes = dict(p.strip().split("=", 1) for p in (cabecera or "").split(",") if "=" in p)
t, v1 = partes.get("t"), partes.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > TOLERANCIA_S:
return False
esperada = hmac.new(secreto.encode(), f"{t}.".encode() + cuerpo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, v1)

app = Flask(__name__)

@app.post("/finora/webhook")
def webhook():
if not firma_valida(os.environ["FINORA_WEBHOOK_SECRET"], request.get_data(), request.headers.get("X-Finora-Signature", "")):
abort(401)
evento = request.get_json(force=True) # después de verificar, nunca antes
return "", 200
PHP
<?php
$cuerpo = file_get_contents('php://input'); // crudo, antes de json_decode
$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_WEBHOOK_SECRET'));
if (!hash_equals($esperada, $partes['v1'])) { http_response_code(401); exit; }
$evento = json_decode($cuerpo, true);
// deduplicar por $_SERVER['HTTP_X_FINORA_ENTREGA'], marcar el pedido
http_response_code(200);

Reintentos, duplicados y EN_DUDA​

  • Responde 2xx en menos de 10 s. Procesa en segundo plano si tu trabajo tarda. Cualquier otra respuesta se reintenta a 1, 5, 25 y 125 minutos (cinco intentos); después la entrega queda FALLIDO y se reenvía desde Webhooks con «Reintentar».
  • Deduplica por X-Finora-Entrega: un reenvío trae el mismo id y el mismo cuerpo.
  • EN_DUDA manda un webhook; cuando la duda se resuelve llega otro con APROBADA o RECHAZADA para la misma sesionId. Trata el último como definitivo y no entregues mientras dure la duda.
  • El webhook es un aviso, no la única verdad: GET /api/v1/checkout/sesiones/{id} con tu clave siempre responde el estado real.

Probar sin pagar​

POST /api/v1/checkout/webhooks/prueba con {"url": "…"} envía checkout.prueba firmado ahora, de forma síncrona, y responde cómo fue:

{"entregaId": 77, "evento": "checkout.prueba", "sesionId": null, "url": "https://tienda.example/finora/webhook",
"estado": "ENTREGADO", "intentos": 1, "httpStatus": 200, "error": null, "proximoIntento": null,
"entregadoEn": "…", "firma": "t=1757800000,v1=…,v0=…", "cuerpo": "{\"evento\":\"checkout.prueba\",…}"}

Compara firma con la que calculaste sobre cuerpo. La consola de pruebas lo hace por ti y te enseña cabecera y cuerpo.

Consultar entregas desde el portal​

MétodoRutaPermiso
GET/api/v1/portal/checkout/webhooks?maxusuario del portal
POST/api/v1/portal/checkout/webhooks/{id}/reintentarportal.claves-api
POST/api/v1/portal/checkout/webhook-secret/regenerarportal.claves-api (el secreto se muestra una vez)