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(secreto, timestamp + "." + cuerpo_crudo)
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
Lo que se firma es el spidi-timestamp, un punto, y el cuerpo crudo:
spidi-signature = HMAC-SHA256( webhook_secret , spidi-timestamp + "." + cuerpo_crudo )
En hexadecimal, y comparado en tiempo constante con el header spidi-signature.
1 — El timestamp entra en el cálculo. Firmar solo el cuerpo no reproduce ninguna firma. La cadena que se firma es exactamente el valor del header spidi-timestamp, seguido de un punto, seguido del cuerpo.
2 — El cuerpo, tal como llegó. 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. Guarda los bytes recibidos y firma sobre esos.
No es decoración: es lo que impide reenviar un aviso capturado. Una firma legítima deja de valer en cuanto se cambia la marca de tiempo, así que quien intercepte un webhook no puede reproducirlo más tarde.
De ahí una consecuencia práctica: si falta el header spidi-timestamp, no hay nada que verificar. Rechaza esa petición en vez de intentar validarla sin él.
JavaScript / Node
import { createHmac, timingSafeEqual } from "crypto";
function firmaValida(rawBody, timestamp, headerFirma, secret) {
if (!timestamp || !headerFirma) return false; // sin timestamp no hay qué verificar
const firmable = `${timestamp}.${rawBody}`; // ← el timestamp entra en el cálculo
const esperada = createHmac("sha256", secret).update(firmable, "utf8").digest("hex");
const a = Buffer.from(esperada), b = Buffer.from(headerFirma);
return a.length === b.length && timingSafeEqual(a, b);
}
// firmaValida(rawBody, req.headers["spidi-timestamp"], req.headers["spidi-signature"], secret)
PHP
<?php
function firma_valida(string $rawBody, string $timestamp, string $headerFirma, string $secret): bool {
if ($timestamp === "" || $headerFirma === "") return false;
$firmable = $timestamp . "." . $rawBody; // ← el timestamp entra
$esperada = hash_hmac("sha256", $firmable, $secret); // hex
return hash_equals($esperada, $headerFirma); // comparación timing-safe
}
Python
import hmac, hashlib
def firma_valida(raw_body: bytes, timestamp: str, header_firma: str, secret: str) -> bool:
if not timestamp or not header_firma:
return False
firmable = timestamp.encode() + b"." + raw_body # ← el timestamp entra
esperada = hmac.new(secret.encode(), firmable, 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 de
spidi-timestamp + "." + cuerpo_crudocon tuwebhook_secret, en hex, comparado de forma timing-safe con el headerspidi-signature.
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. 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");
const ts = req.headers["spidi-timestamp"]; // entra en la firma
if (!firmaValida(raw, ts, req.headers["spidi-signature"], WEBHOOK_SECRET))
return res.status(401).end(); // firma inválida o sin timestamp
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 (fase 1) salió bien. Esto no significa que el dinero ya esté en manos del receptor.payment_session.accredited— el dinero se acreditó (fase 2). ⚠ Este no se refleja enGET status, que se queda enpaid. Pero el aviso no es tu única vía: el mismo dato está endata.session_payment.receiver_credits, en la respuesta del status. → Transacción a dos fases.
Recibirás un evento de acreditación por cada transacción de crédito, uno por cada parte especificada en el acuerdo:
- Sin split → un solo
payment_session.accredited. - Con split → una por receptor (
payment_session.accreditation_to_recipient_completed, en desarrollo en el contrato) más el cierre (payment_session.accredited), todas a tu mismo endpoint. Distíngue cada una por sueventy por eliddel receptor en el payload; cada llamada trae su propioidempotency-key. → Split.
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.