Firma y seguridad de webhooks
Estado documental: Contrato propuesto. Los nombres de headers, el algoritmo y la tolerancia temporal deben confirmarse mediante implementación y pruebas.
Secreto independiente
webhook_secret
≠ client_secret
≠ access_token
≠ checkout token
YUPY genera el secreto de webhooks y lo entrega mediante un mecanismo seguro.
Headers propuestos
Yupy-Event-Id: evt_01JXYZ
Yupy-Timestamp: 1784567890
Yupy-Signature: v1=<HEX_SIGNATURE>
Contenido firmado
timestamp + "." + raw_body
Algoritmo propuesto:
HMAC-SHA256
La firma se calcula sobre el cuerpo original recibido. No debe volver a serializarse el JSON antes de validar.
Verificación en PHP
<?php
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_YUPY_TIMESTAMP'] ?? '';
$signatureHeader = $_SERVER['HTTP_YUPY_SIGNATURE'] ?? '';
$webhookSecret = getenv('YUPY_WEBHOOK_SECRET');
if (
$timestamp === ''
|| $signatureHeader === ''
|| !$webhookSecret
) {
http_response_code(400);
exit('Missing webhook security data');
}
if (!str_starts_with($signatureHeader, 'v1=')) {
http_response_code(401);
exit('Invalid signature format');
}
$receivedSignature = substr($signatureHeader, 3);
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac(
'sha256',
$signedPayload,
$webhookSecret
);
if (!hash_equals(
$expectedSignature,
$receivedSignature
)) {
http_response_code(401);
exit('Invalid signature');
}
$toleranceSeconds = 300;
if (
abs(time() - (int) $timestamp)
> $toleranceSeconds
) {
http_response_code(401);
exit('Webhook timestamp outside tolerance');
}
$event = json_decode($rawBody, true);
if (!is_array($event) || empty($event['event_id'])) {
http_response_code(400);
exit('Invalid payload');
}
http_response_code(200);
header('Content-Type: application/json');
echo json_encode([
'received' => true,
]);
Verificación en Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/yupy",
express.raw({ type: "application/json" }),
async (req, res) => {
const timestamp = req.header("Yupy-Timestamp");
const signatureHeader = req.header("Yupy-Signature");
const webhookSecret =
process.env.YUPY_WEBHOOK_SECRET;
if (!timestamp || !signatureHeader || !webhookSecret) {
return res.status(400).json({
error: "missing_webhook_security_data",
});
}
if (!signatureHeader.startsWith("v1=")) {
return res.status(401).json({
error: "invalid_signature_format",
});
}
const receivedSignature =
signatureHeader.slice(3);
const rawBody = req.body.toString("utf8");
const signedPayload =
`${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(signedPayload)
.digest("hex");
const expectedBuffer =
Buffer.from(expectedSignature, "utf8");
const receivedBuffer =
Buffer.from(receivedSignature, "utf8");
if (
expectedBuffer.length !== receivedBuffer.length ||
!crypto.timingSafeEqual(
expectedBuffer,
receivedBuffer
)
) {
return res.status(401).json({
error: "invalid_signature",
});
}
const event = JSON.parse(rawBody);
// Guardar event.event_id antes de procesar.
// Responder primero y procesar después.
return res.status(200).json({
received: true,
});
}
);
Protección contra repetición
Se utilizan conjuntamente:
timestamp
event_id
firma
Aunque una solicitud tenga firma válida, el cliente no debe procesar dos veces el mismo event_id.
Rotación
Durante una transición, el cliente puede aceptar:
secreto actual
secreto anterior
Después de finalizar la rotación, el secreto anterior se revoca.
Fallo de firma
HTTP/1.1 401 Unauthorized
El cliente debe registrar fecha, origen, event_id cuando exista, razón del rechazo y hash del payload. No debe registrar el secreto.
Criterios de aceptación
- El secreto de webhook es independiente.
- YUPY genera y entrega el secreto.
- La firma usa el cuerpo original.
- La comparación es segura.
- El timestamp limita repeticiones antiguas.
event_idevita doble procesamiento.- La rotación puede aceptar dos secretos temporalmente.
- Los secretos no aparecen en logs.
- Una firma inválida devuelve
401. - El algoritmo y headers permanecen propuestos hasta verificación.