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: verificar, deduplicar, responderGuion: .docx · .yaml

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.

Los dos detalles que hacen fallar la verificación

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.

Por qué el timestamp está ahí

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_crudo con tu webhook_secret, en hex, comparado de forma timing-safe con el header spidi-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 en GET status, que se queda en paid. Pero el aviso no es tu única vía: el mismo dato está en data.session_payment.receiver_credits, en la respuesta del status. → Transacción a dos fases.
La acreditación llega un aviso por cada parte del acuerdo

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 su event y por el id del receptor en el payload; cada llamada trae su propio idempotency-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.