Manejar notificaciones (firma + idempotencia)
Cuando una sesión cambia de estado, SPIDI POSTea un aviso (webhook) a tu webhook_url. Tu endpoint debe hacer tres cosas: verificar la firma, deduplicar y responder 2xx.
Cómo llega un aviso
POST /webhooks/spidi
spidi-signature: 9e9a755daf7eb072… ← HMAC-SHA256(cuerpo_crudo, webhook_secret)
spidi-timestamp: 2026-06-26T13:13:28Z
idempotency-key: 7306b8bd-70dd-4f…
content-type: application/json
{ "event": "payment_session.paid", ... }
Tu webhook_secret lo obtienes al crear tu cuenta (en el simulador, en el Paso 0 del tutorial).
1. Verifica la firma — sobre el cuerpo CRUDO
Calcula HMAC-SHA256(webhook_secret, cuerpo_crudo) en hex y compáralo (timing-safe) con el header spidi-signature.
Si parseas el JSON y lo vuelves a serializar, el orden de las claves o los espacios pueden cambiar y la firma dejará de coincidir. Conserva el cuerpo crudo tal como llegó y firma sobre eso.
JavaScript / Node
import { createHmac, timingSafeEqual } from "crypto";
function firmaValida(rawBody, headerFirma, secret) {
const esperada = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
const a = Buffer.from(esperada), b = Buffer.from(headerFirma);
return a.length === b.length && timingSafeEqual(a, b);
}
PHP
<?php
function firma_valida(string $rawBody, string $headerFirma, string $secret): bool {
$esperada = hash_hmac("sha256", $rawBody, $secret); // hex
return hash_equals($esperada, $headerFirma); // comparación timing-safe
}
Python
import hmac, hashlib
def firma_valida(raw_body: bytes, header_firma: str, secret: str) -> bool:
esperada = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, header_firma) # comparación timing-safe
En los tres casos el cálculo es el mismo: HMAC-SHA256 del cuerpo crudo con tu
webhook_secret, en hex, comparado de forma timing-safe con el headerspidi-signature. No se incluye elspidi-timestampen la firma.
2. Deduplica por idempotency-key
El mismo aviso puede llegar más de una vez (reintentos). Guarda los idempotency-key ya vistos y ignora los repetidos (responde 200 sin reprocesar).
3. Responde 2xx
Si devuelves un error o tardas demasiado, SPIDI reintenta (hasta 3 veces). Procesa rápido y responde 200; si necesitas trabajo pesado, encólalo y responde de inmediato.
Endpoint completo (Express)
import express from "express";
const app = express();
const vistos = new Set();
// conserva el cuerpo CRUDO para verificar la firma
app.use("/webhooks/spidi", express.raw({ type: "application/json" }));
app.post("/webhooks/spidi", (req, res) => {
const raw = req.body.toString("utf8");
if (!firmaValida(raw, req.headers["spidi-signature"], WEBHOOK_SECRET))
return res.status(401).end(); // firma inválida
const key = req.headers["idempotency-key"];
if (vistos.has(key)) return res.status(200).end(); // repetido → no reprocesar
vistos.add(key);
const evento = JSON.parse(raw);
procesar(evento); // tu lógica
res.status(200).end();
});
Los eventos que escucharás
payment_session.paid— el débito (leg 1) salió bien. Esto no significa que el dinero ya esté en manos del beneficiario.payment_session.accredited— el dinero se acreditó (leg 2). ⚠ Este no se refleja enGET status(se queda enpaid); solo llega por aquí. → Transacción a dos tiempos.
Pruébalo contra el simulador
El simulador firma los avisos igual que producción y reintenta. Puedes comprobar tu verificación —y que un payload alterado se rechaza— forzando un paid y observando el POST a tu endpoint.