> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bbrands.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Entrega y firma

> Cómo es una entrega en el cable y cómo verificar la cabecera x-bbrands-signature en Node.js y Python.

## La petición

Cada entrega es un `POST` HTTPS con cuerpo JSON y estas cabeceras:

| Cabecera                | Valor                                                                              |
| ----------------------- | ---------------------------------------------------------------------------------- |
| `content-type`          | `application/json`                                                                 |
| `user-agent`            | `BBrandsLab-Webhook/1.0`                                                           |
| `x-bbrands-signature`   | `t=<segundos unix>,v1=<HMAC-SHA256 hex>`                                           |
| `x-bbrands-event`       | Internal del catálogo, p. ej. `payment-debt.created`                               |
| `x-bbrands-delivery-id` | Id de entrega (`webhook_dispatch.document_id`); estable entre reintentos y replays |
| `x-bbrands-webhook-id`  | Id de tu webhook                                                                   |

```json Cuerpo theme={null}
{
  "api_version": "2026-09-11",
  "attempt": 1,
  "data": {
    "account": "8f3b1c2e-0d4a-4f2b-9c1e-6a7b8c9d0e1f",
    "document_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
    "resource_type": "payment-debt"
  },
  "event": "payment-debt.created",
  "id": "7d6c5b4a-3f2e-4d1c-9b0a-8f7e6d5c4b3a",
  "occurred_at": "2026-09-11T12:00:00.000Z",
  "route": { "method": "POST", "path": "/api/v3/payment/debt" },
  "webhook_id": "2f9a7c41-5d3e-4b8a-9f01-c2d3e4f5a6b7"
}
```

| Campo                | Significado                                                                                                       |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `api_version`        | Versión del contrato del cuerpo. Solo cambia con rupturas de compatibilidad.                                      |
| `attempt`            | `1` en el primer `POST`, se incrementa en cada reintento. Es el único campo que cambia entre intentos.            |
| `data.account`       | Cuenta propietaria resuelta, o `null` en eventos con alcance `none`.                                              |
| `data.document_id`   | Id del recurso mutado, tomado de la respuesta de la API. `null` cuando esa respuesta no traía `data.document_id`. |
| `data.resource_type` | Internal del evento sin la acción, p. ej. `payment-debt`.                                                         |
| `event`              | Internal del catálogo (igual que `x-bbrands-event`).                                                              |
| `id`                 | Id de entrega (igual que `x-bbrands-delivery-id`). Úsalo para idempotencia.                                       |
| `occurred_at`        | Cuándo se completó la transacción en la API.                                                                      |
| `route`              | Método y ruta de la petición original.                                                                            |
| `webhook_id`         | Id de tu webhook (igual que `x-bbrands-webhook-id`).                                                              |

<Info>
  El cuerpo es fino a propósito: dice *qué* cambió, no *cómo*. Lee el recurso
  actual por la API REST con tus propias credenciales — así además ves el
  último estado y no el que había al publicar.
</Info>

## Cómo se calcula la firma

```text theme={null}
signed_payload = "<t>" + "." + <cuerpo crudo de la petición>
v1             = hex( HMAC_SHA256( clave = secret, mensaje = signed_payload ) )
cabecera       = "t=<t>,v1=<v1>"
```

* `secret` es el valor `whsec_…` devuelto una vez por create o rotate-secret.
* `t` es la hora Unix (segundos) en que el worker firmó la petición.
* El cuerpo crudo es la secuencia exacta de bytes enviada; cualquier cambio de
  espacios u orden de claves invalida el digest.

## Reglas de verificación

<Steps>
  <Step title="Lee el cuerpo crudo">
    Captura el cuerpo **antes** de que un middleware JSON lo parsee.
    Reserializar el objeto parseado cambia los bytes.
  </Step>

  <Step title="Parsea la cabecera">
    Espera exactamente `t=<dígitos>,v1=<64 hex>`. Rechaza cualquier otra cosa.
  </Step>

  <Step title="Comprueba la marca de tiempo">
    Rechaza cuando `|ahora - t| > 300` segundos. Esto acota el replay de una
    petición capturada; mantén el reloj sincronizado (NTP).
  </Step>

  <Step title="Compara en tiempo constante">
    Recalcula el HMAC y compáralo con una función timing-safe.
  </Step>

  <Step title="Deduplica y confirma">
    Reintentos y replays reutilizan `id`. Registra los ids procesados,
    responde `2xx` en menos de 10 segundos y haz el trabajo real de forma
    asíncrona.
  </Step>
</Steps>

## Implementaciones de referencia

<Tabs>
  <Tab title="Node.js">
    ```js Node.js theme={null}
    import { createHmac, timingSafeEqual } from "node:crypto";

    const TOLERANCE_SECONDS = 300;
    const SIGNATURE_PATTERN = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/;

    /**
     * Verifies an `x-bbrands-signature` header against the raw request body.
     *
     * @param {string} secret   The `whsec_…` value returned once by create or rotate-secret.
     * @param {string} header   The `x-bbrands-signature` header.
     * @param {string} rawBody  The request body exactly as received (no re-serialisation).
     * @param {number} [now]    Current time in milliseconds (injectable for tests).
     */
    export function verifyBBrandsSignature(secret, header, rawBody, now = Date.now()) {
      const match = SIGNATURE_PATTERN.exec(header ?? "");
      if (!match) return { valid: false, reason: "malformed" };

      const timestamp = Number(match[1]);
      if (Math.abs(Math.floor(now / 1000) - timestamp) > TOLERANCE_SECONDS) {
        return { valid: false, reason: "expired" };
      }

      const expected = createHmac("sha256", secret)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex");
      const a = Buffer.from(expected, "hex");
      const b = Buffer.from(match[2], "hex");
      if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return { valid: false, reason: "mismatch" };
      }
      return { valid: true, timestamp };
    }

    // Express: keep the raw body so the HMAC is computed over the original bytes.
    app.post(
      "/hooks/bbrands",
      express.raw({ type: "application/json" }),
      (req, res) => {
        const result = verifyBBrandsSignature(
          process.env.BBRANDS_WEBHOOK_SECRET,
          req.get("x-bbrands-signature"),
          req.body.toString("utf8"),
        );
        if (!result.valid) return res.status(401).end();

        const delivery = JSON.parse(req.body.toString("utf8"));
        // Deduplicate on delivery.id, then hand the work to a queue.
        res.status(202).end();
      },
    );
    ```
  </Tab>

  <Tab title="Python">
    ```python Python theme={null}
    import hashlib
    import hmac
    import re
    import time

    TOLERANCE_SECONDS = 300
    SIGNATURE_PATTERN = re.compile(r"^t=(\d{1,12}),v1=([0-9a-f]{64})$")


    def verify_bbrands_signature(
        secret: str,
        header: str | None,
        raw_body: bytes,
        now: float | None = None,
    ) -> bool:
        """Verify an ``x-bbrands-signature`` header against the raw request body."""
        match = SIGNATURE_PATTERN.match((header or "").strip())
        if not match:
            return False
        timestamp = int(match.group(1))
        current = int(now if now is not None else time.time())
        if abs(current - timestamp) > TOLERANCE_SECONDS:
            return False
        expected = hmac.new(
            secret.encode("utf-8"),
            f"{timestamp}.".encode("utf-8") + raw_body,
            hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(expected, match.group(2))


    # FastAPI: read the raw body before any JSON parsing.
    @app.post("/hooks/bbrands", status_code=202)
    async def bbrands_hook(request: Request):
        raw = await request.body()
        if not verify_bbrands_signature(
            SECRET, request.headers.get("x-bbrands-signature"), raw
        ):
            raise HTTPException(status_code=401)
        delivery = json.loads(raw)
        # Deduplicate on delivery["id"], then hand the work to a queue.
        return {}
    ```
  </Tab>
</Tabs>

Reflejan `verifyOutboundWebhookSignature` en
`backend/horizon-api/lib/webhook/outbound/webhook-signature.ts`, que es lo
que también usa el sink de pruebas integrado.

## Respuesta

| Tu respuesta                                    | Comportamiento de la plataforma                                              |
| ----------------------------------------------- | ---------------------------------------------------------------------------- |
| `2xx`                                           | `success`; `consecutive_failures` vuelve a 0.                                |
| `408`, `429`, `5xx`, timeout, error de conexión | Reintentable; se programa según el backoff.                                  |
| Otros `4xx`                                     | `error` terminal para esta entrega; cuenta para la desactivación automática. |
| `3xx`                                           | Nunca se sigue. `error` terminal; cuenta para la desactivación automática.   |

Detalles en [Reintentos y fallos](/es/webhooks/retries-and-failures).

<Warning>
  No pongas lógica de negocio antes de la verificación de firma. Un endpoint
  que parsea el cuerpo primero y verifica después es trivialmente suplantable.
</Warning>
