> ## 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.

# Delivery and signature

> What a delivery looks like on the wire and how to verify the x-bbrands-signature header in Node.js and Python.

## The request

Every delivery is an HTTPS `POST` with a JSON body and these headers:

| Header                  | Value                                                                           |
| ----------------------- | ------------------------------------------------------------------------------- |
| `content-type`          | `application/json`                                                              |
| `user-agent`            | `BBrandsLab-Webhook/1.0`                                                        |
| `x-bbrands-signature`   | `t=<unix seconds>,v1=<hex HMAC-SHA256>`                                         |
| `x-bbrands-event`       | Catalogue internal, e.g. `payment-debt.created`                                 |
| `x-bbrands-delivery-id` | Delivery id (`webhook_dispatch.document_id`); stable across retries and replays |
| `x-bbrands-webhook-id`  | Your webhook id                                                                 |

```json Body 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"
}
```

| Field                | Meaning                                                                                                                      |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `api_version`        | Contract version of the body. Bumps only on breaking changes.                                                                |
| `attempt`            | `1` on the first `POST`, incremented on each retry. The only field that changes between attempts.                            |
| `data.account`       | Resolved owning account, or `null` for `none`-scoped events.                                                                 |
| `data.document_id`   | Id of the mutated resource, taken from the API response. `null` when the originating response carried no `data.document_id`. |
| `data.resource_type` | Event internal without the action, e.g. `payment-debt`.                                                                      |
| `event`              | Catalogue internal (same as `x-bbrands-event`).                                                                              |
| `id`                 | Delivery id (same as `x-bbrands-delivery-id`). Use it for idempotency.                                                       |
| `occurred_at`        | When the API transaction completed.                                                                                          |
| `route`              | Method and path of the originating request.                                                                                  |
| `webhook_id`         | Your webhook id (same as `x-bbrands-webhook-id`).                                                                            |

<Info>
  The body is intentionally thin: it tells you *what* changed, not *how*.
  Read the current resource through the REST API with your own credentials —
  that also guarantees you see the latest state rather than the state at
  publish time.
</Info>

## How the signature is computed

```text theme={null}
signed_payload = "<t>" + "." + <raw request body>
v1             = hex( HMAC_SHA256( key = secret, message = signed_payload ) )
header         = "t=<t>,v1=<v1>"
```

* `secret` is the `whsec_…` value returned once by create or rotate-secret.
* `t` is the Unix time (seconds) when the worker signed the request.
* The raw body is the exact byte sequence sent; whitespace or key order
  changes invalidate the digest.

## Verification rules

<Steps>
  <Step title="Read the raw body">
    Capture the body **before** any JSON middleware parses it. Re-serialising
    the parsed object changes the bytes.
  </Step>

  <Step title="Parse the header">
    Expect exactly `t=<digits>,v1=<64 hex chars>`. Reject anything else.
  </Step>

  <Step title="Check the timestamp">
    Reject when `|now - t| > 300` seconds. This bounds replay of a captured
    request; keep your server clock in sync (NTP).
  </Step>

  <Step title="Compare in constant time">
    Recompute the HMAC and compare with a timing-safe function.
  </Step>

  <Step title="Deduplicate and acknowledge">
    Retries and operator replays reuse `id`. Record processed ids, return
    `2xx` within 10 seconds, and do the real work asynchronously.
  </Step>
</Steps>

## Reference implementations

<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>

These mirror `verifyOutboundWebhookSignature` in
`backend/horizon-api/lib/webhook/outbound/webhook-signature.ts`, which is
also what the built-in test sink uses.

## Responding

| Your response                                  | Platform behaviour                                              |
| ---------------------------------------------- | --------------------------------------------------------------- |
| `2xx`                                          | `success`; `consecutive_failures` reset to 0.                   |
| `408`, `429`, `5xx`, timeout, connection error | Retryable; scheduled per the backoff.                           |
| Other `4xx`                                    | Terminal `error` for this delivery; counts toward auto-disable. |
| `3xx`                                          | Never followed. Terminal `error`; counts toward auto-disable.   |

Details in [Retries and failures](/webhooks/retries-and-failures).

<Warning>
  Do not put business logic in front of the signature check. An endpoint that
  parses the body first and verifies later is trivially spoofable.
</Warning>
