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

# Webhook Security

> Verify that a webhook genuinely came from BillStack using the signature headers on every request.

Because your webhook URL is public, anyone could try to POST fake events to it. BillStack signs every webhook with your **secret key** so you can confirm a request really came from us and was not altered in transit. Verify the signature before you trust an event.

Each webhook arrives with these headers:

| Header                  | Description                                                                                              |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `x-wiaxy-signature-256` | **The signature to verify.** HMAC-SHA256 (hex) of `"{timestamp}.{rawBody}"`, keyed with your secret key. |
| `x-wiaxy-timestamp`     | The Unix timestamp (seconds) used in the signed string.                                                  |
| `x-wiaxy-signature`     | **Legacy.** A static `md5(secret_key)`, kept for backward compatibility only.                            |

## The new signature (recommended)

Verify `x-wiaxy-signature-256`. It is computed over the exact body of each request, so it proves both **authenticity** (only someone with your secret key could produce it) and **integrity** (any change to the body invalidates it).

To verify:

1. Read `x-wiaxy-timestamp` and the raw, unparsed request body.
2. Build the string `"{timestamp}.{rawBody}"`.
3. Compute `HMAC-SHA256` of that string with your secret key, as hex.
4. Compare it to `x-wiaxy-signature-256` using a constant-time comparison.
5. Reject the request if the timestamp is older than a few minutes, to stop replays.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "crypto";

  function verifyWebhook(rawBody, headers, secretKey) {
    const timestamp = headers["x-wiaxy-timestamp"];
    const signature = headers["x-wiaxy-signature-256"];

    // Reject stale events (replay protection).
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

    const expected = crypto
      .createHmac("sha256", secretKey)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }
  ```

  ```php PHP theme={null}
  function verify_webhook($rawBody, $headers, $secretKey) {
      $timestamp = $headers['x-wiaxy-timestamp'] ?? '';
      $signature = $headers['x-wiaxy-signature-256'] ?? '';

      // Reject stale events (replay protection).
      if (abs(time() - (int)$timestamp) > 300) return false;

      $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secretKey);

      return hash_equals($expected, $signature);
  }
  ```
</CodeGroup>

<Warning>
  Always verify against the **raw, unparsed request body** : exactly the bytes you received. Re-serialising the JSON (for example `JSON.stringify(req.body)`) can reorder keys or change whitespace, producing a different signature and a failed check.
</Warning>

## The legacy signature

Older integrations may still check `x-wiaxy-signature`, a static `md5(secret_key)`. Because it never changes between requests, it does not prove the body is intact and is weaker than the HMAC signature.

It is still sent for backward compatibility, but new integrations should verify `x-wiaxy-signature-256` instead.

<Note>
  Both headers are sent on every webhook, so you can migrate from the legacy signature to the new one without any change on our side : just start verifying `x-wiaxy-signature-256`.
</Note>
