API Technical Docs

Mantente al día con las innovaciones tecnológicas que están transformando el mercado.

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

  1. El secreto de webhook es independiente.
  2. YUPY genera y entrega el secreto.
  3. La firma usa el cuerpo original.
  4. La comparación es segura.
  5. El timestamp limita repeticiones antiguas.
  6. event_id evita doble procesamiento.
  7. La rotación puede aceptar dos secretos temporalmente.
  8. Los secretos no aparecen en logs.
  9. Una firma inválida devuelve 401.
  10. El algoritmo y headers permanecen propuestos hasta verificación.