Webhooks
PayForce avisa a tu servidor con un POST JSON. Registra la URL en el dashboard o con POST /api/v1/webhooks. Guarda el secret: solo se muestra al crearlo.
Eventos
payment.succeeded— el cobro entró.payment.failed— el cobro fue rechazado.payment.refunded— hay una devolución.checkout.completed— la sesión de checkout terminó.dispute.created— el comprador abrió una disputa.
Cabeceras
X-PayForce-Event— nombre del evento.X-PayForce-Delivery-Id— id de este intento de entrega.X-PayForce-Signature—t=<ms>,v1=<hex>.
Formato del payload
{
"event": "payment.succeeded",
"payment": {
"id": "pf_pay_test",
"amount": 1990,
"currency": "eur",
"status": "succeeded",
"description": "Evento de prueba",
"customer_email": "test@example.com",
"created_at": "2026-09-11T14:06:02.000Z"
}
}payment.failed añade failure_code. checkout.completed envía object con payment_id. dispute.created envía dispute.
Verificar la firma HMAC
La firma es HMAC-SHA256 de timestamp + "." + body, en hex. Tolera 300 segundos de desfase de reloj. Compara en tiempo constante.
const crypto = require("crypto");
function verifyPayForceSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1)];
}),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = crypto.createHash("sha256").update(expected).digest();
const b = crypto.createHash("sha256").update(v1).digest();
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
const ts = Number(t);
const tsSec = ts > 1e12 ? Math.floor(ts / 1000) : Math.floor(ts);
return Math.abs(Math.floor(Date.now() / 1000) - tsSec) <= 300;
}
app.post("/webhooks/payforce", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const ok = verifyPayForceSignature(
rawBody,
req.get("x-payforce-signature") || "",
process.env.PAYFORCE_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("invalid signature");
const event = JSON.parse(rawBody);
// … marca el pedido …
res.json({ received: true });
});Con el SDK: pf.webhooks.verify(rawBody, header, secret).
Reintentos
Si tu endpoint no responde 2xx, PayForce reintenta 6 veces. La espera entre intentos es 1 min, 5 min, 30 min, 2 h, 12 h y 24 h. El timeout de cada POST es de 10 segundos. Un 3xx no cuenta como éxito.
Usa X-PayForce-Delivery-Id para no procesar dos veces el mismo envío. El mismo evento puede llegar más de una vez si el primer 2xx se perdió al registrar la entrega.
