Saltar al contenido principal

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.

Manejar notificaciones: firma, idempotencia y respuesta 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.

Verifica sobre los bytes recibidos, no sobre el JSON re-serializado

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 header spidi-signature. No se incluye el spidi-timestamp en 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 en GET status (se queda en paid); 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.