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

# Verify signatures

> Check that every webhook came from Saasybill and hasn't been replayed

Anyone who learns your webhook URL can send a request to it. Without a check, they could post "invoice paid" and your platform would believe it. Saasybill signs every request so you can tell a real one from a forgery.

<Warning>
  Always verify the signature before you act on a webhook. Reject any request that fails.
</Warning>

***

## The signature header

Every request has a `Saasybill-Signature` header.

```text theme={null}
Saasybill-Signature: t=1790741520,v1=9c1e4f7a2b8d3065e1a7c94b0d2f83e6a5b1c7d90e4f2a86b3d5c1e07f9a4b28
```

| Part | Description |
| - | - |
| `t` | The time the request was signed, in Unix seconds. |
| `v1` | A signature, in hex. There can be two `v1` values while a rolled secret's old value is still valid. |

***

## How to verify

<Steps>
  <Step title="Split the header">
    Read `t` and every `v1` from the header.
  </Step>

  <Step title="Check the timestamp">
    Reject the request if `t` is more than five minutes from your clock. This stops someone replaying an old, genuine request.
  </Step>

  <Step title="Compute the expected signature">
    Build the string `<t>.<raw body>`: the timestamp, a full stop, then the request body exactly as received. Compute its HMAC-SHA256 with your signing secret as the key, and encode it as hex.
  </Step>

  <Step title="Compare">
    Compare your result with each `v1` in constant time. If any matches, the request is genuine.
  </Step>
</Steps>

```text theme={null}
signed payload = "<t>" + "." + "<raw request body>"
signature      = hex( HMAC-SHA256( secret, signed payload ) )
```

Three details matter.

* **Use the raw body.** A body that was parsed and serialised again isn't the bytes Saasybill signed. Read the body as bytes or as a string before your framework parses it.
* **Use the whole secret.** The key is the complete signing secret, including the `whsec_` prefix.
* **Compare in constant time.** Use your language's constant-time comparison, not `==`.

***

## Code

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

  const app = express();
  const secret = process.env.SAASYBILL_WEBHOOK_SECRET; // whsec_…

  // Read the body as raw bytes. Don't use express.json() on this route.
  app.post("/saasybill-webhooks", express.raw({ type: "application/json" }), (req, res) => {
    const body = req.body.toString("utf8");

    if (!verifySignature(body, req.get("Saasybill-Signature") ?? "", secret)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(body);
    // Skip repeats using event.id, then handle event.type here.

    res.sendStatus(200);
  });

  function verifySignature(body, header, secret, toleranceSeconds = 300) {
    let timestamp = null;
    const signatures = [];

    for (const part of header.split(",")) {
      const [key, value] = part.trim().split("=");
      if (key === "t" && /^\d+$/.test(value ?? "")) timestamp = Number(value);
      if (key === "v1" && value) signatures.push(value);
    }

    if (timestamp === null || signatures.length === 0) return false;
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${body}`, "utf8")
      .digest();

    return signatures.some((signature) => {
      const actual = Buffer.from(signature, "hex");
      return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
    });
  }

  app.listen(3000);
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["SAASYBILL_WEBHOOK_SECRET"]  # whsec_…


  def verify_signature(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      timestamp, signatures = None, []

      for part in header.split(","):
          key, _, value = part.strip().partition("=")
          if key == "t" and value.isdigit():
              timestamp = int(value)
          elif key == "v1" and value:
              signatures.append(value)

      if timestamp is None or not signatures:
          return False
      if abs(time.time() - timestamp) > tolerance:
          return False

      signed_payload = f"{timestamp}.".encode() + body
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

      return any(hmac.compare_digest(expected, signature) for signature in signatures)


  @app.post("/saasybill-webhooks")
  def saasybill_webhooks():
      body = request.get_data()  # the raw bytes

      if not verify_signature(body, request.headers.get("Saasybill-Signature", ""), SECRET):
          return "", 400

      event = request.get_json()
      # Skip repeats using event["id"], then handle event["type"] here.

      return "", 200
  ```

  ```php PHP theme={null}
  <?php

  function verifySignature(string $body, string $header, string $secret, int $tolerance = 300): bool
  {
      $timestamp = null;
      $signatures = [];

      foreach (explode(',', $header) as $part) {
          [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
          if ($key === 't' && ctype_digit($value)) {
              $timestamp = (int) $value;
          }
          if ($key === 'v1' && $value !== '') {
              $signatures[] = $value;
          }
      }

      if ($timestamp === null || count($signatures) === 0) {
          return false;
      }
      if (abs(time() - $timestamp) > $tolerance) {
          return false;
      }

      $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);

      foreach ($signatures as $signature) {
          if (hash_equals($expected, $signature)) {
              return true;
          }
      }

      return false;
  }

  $body = file_get_contents('php://input'); // the raw body
  $header = $_SERVER['HTTP_SAASYBILL_SIGNATURE'] ?? '';

  if (!verifySignature($body, $header, getenv('SAASYBILL_WEBHOOK_SECRET'))) {
      http_response_code(400);
      exit;
  }

  $event = json_decode($body, true);
  // Skip repeats using $event['id'], then handle $event['type'] here.

  http_response_code(200);
  ```
</CodeGroup>

***

## Test your check

<Steps>
  <Step title="Send a test event">
    Open the endpoint's menu in **Developer Settings**, then **Webhooks**, and click <Badge>Send Test Event</Badge>.
  </Step>

  <Step title="Confirm it passes">
    Your handler should verify the signature and return `200`. The delivery shows as <Badge color="green">Delivered</Badge> under **Recent Deliveries**.
  </Step>

  <Step title="Confirm a forgery fails">
    Send a request to your endpoint yourself, with a wrong `Saasybill-Signature`. It should return `400`.
  </Step>
</Steps>

***

## Rolling your secret

Roll the secret if it might have leaked, or on a schedule.

<Steps>
  <Step title="Roll it">
    Open the endpoint's menu and click <Badge>Roll Secret</Badge>. Copy the new secret from the **Your New Signing Secret** dialog.
  </Step>

  <Step title="Deploy the new secret">
    The old secret keeps signing for 24 hours. During that time each request carries two `v1` values, one per secret. The code above accepts either, so you can deploy without missing an event.
  </Step>

  <Step title="Done">
    After 24 hours, only the new secret signs.
  </Step>
</Steps>

<Note>
  A request that fails verification with the right secret usually means the body was changed before you hashed it. Check that no middleware parsed or re-encoded it.
</Note>
