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

# Verify Webhook Signatures

> Confirm a webhook request came from Bindbee before you act on it.

Your webhook URL is public, so anyone can send a request to it. Verify the signature on every request and reject the ones that fail, before you parse or act on the body.

Version 2 signs every delivery according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification. The signature covers the event ID, a timestamp and the body, so a captured request can't be replayed later.

<Info>
  **Before you start**

  * A webhook on version 2 - see [Create a webhook](/guides/webhooks/create-a-webhook). Version 1 webhooks carry only the legacy signature - see [Verify the legacy signature](#verify-the-legacy-signature).
  * Your handler can read the request body as raw bytes, before any JSON parsing.
</Info>

## Steps

<Steps>
  <Step title="Copy your signing secret">
    On the **[Webhooks](https://app.bindbee.dev/webhooks)** page, find the **Security signature** card. Click the eye icon to reveal the secret, then copy it.

    <Frame>
      <img src="https://mintcdn.com/unifyx-56/NU46D4mywOWu_9HZ/images/platform/webhook.png?fit=max&auto=format&n=NU46D4mywOWu_9HZ&q=85&s=474ba87c3b18a4e659418a28f3bcbbe0" alt="The Webhooks page with the Security signature card above the webhook list, its masked secret flanked by an eye icon on the left and copy and refresh icons on the right" width="2582" height="1506" data-path="images/platform/webhook.png" />
    </Frame>

    Store it where your handler reads configuration, for example an environment variable named `BINDBEE_WEBHOOK_SECRET`. One secret signs every webhook in your organization, on both versions.
  </Step>

  <Step title="Read the raw body and the signature headers">
    Every version 2 request carries three headers:

    | Header | Contains |
    | - | - |
    | `webhook-id` | The event ID |
    | `webhook-timestamp` | When Bindbee sent the event, in Unix seconds |
    | `webhook-signature` | `v1,` followed by a base64 HMAC-SHA256 signature |

    Capture the body exactly as it arrived. The signature covers those bytes, and parsing then re-serializing the JSON changes them.
  </Step>

  <Step title="Verify the signature">
    Use the `standardwebhooks` library for your language. It checks the signature and rejects a timestamp more than five minutes from your server's clock.

    The libraries expect the secret in `whsec_` form. Build it by prefixing `whsec_` to the base64 encoding of your secret, as the Node sample does. The Python library also accepts the secret's raw bytes directly.

    <CodeGroup>
      ```python Python theme={null}
      # pip install standardwebhooks flask
      import os

      from flask import Flask, abort, request
      from standardwebhooks.webhooks import Webhook, WebhookVerificationError

      app = Flask(__name__)
      webhook = Webhook(os.environ["BINDBEE_WEBHOOK_SECRET"].encode("utf-8"))

      @app.post("/webhooks/bindbee")
      def bindbee_webhook():
          try:
              event = webhook.verify(request.get_data(), dict(request.headers))
          except WebhookVerificationError:
              abort(401)

          enqueue(event)  # process after responding
          return "", 200
      ```

      ```javascript Node theme={null}
      // npm install standardwebhooks express
      const express = require("express");
      const { Webhook } = require("standardwebhooks");

      const app = express();
      const secret = process.env.BINDBEE_WEBHOOK_SECRET;
      const webhook = new Webhook("whsec_" + Buffer.from(secret, "utf8").toString("base64"));

      // express.raw keeps the body as the exact bytes Bindbee signed
      app.post("/webhooks/bindbee", express.raw({ type: "application/json" }), (req, res) => {
        let event;
        try {
          event = webhook.verify(req.body, req.headers);
        } catch (err) {
          return res.status(401).send("Invalid signature");
        }

        res.sendStatus(200);
        enqueue(event); // process after responding
      });
      ```
    </CodeGroup>
  </Step>

  <Step title="Deduplicate on the event ID">
    A retried delivery carries the same `webhook-id` and the same `id` in the body. Record each ID you process, and skip one you've already seen - see [Delivery and retries](/guides/webhooks/delivery#deduplicate-events).
  </Step>
</Steps>

## Verify without a library

Sign `{webhook-id}.{webhook-timestamp}.{body}` with HMAC-SHA256, keyed with the secret's UTF-8 bytes, and compare the standard base64 result with each `v1,` entry in `webhook-signature`.

<CodeGroup>
  ```python Python theme={null}
  import base64
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 5 \* 60

  def verify_bindbee_signature(secret: str, headers: dict, raw_body: bytes) -> bool:
  headers = {k.lower(): v for k, v in headers.items()}
  msg_id = headers.get("webhook-id")
  timestamp = headers.get("webhook-timestamp")
  signatures = headers.get("webhook-signature")
  if not (msg_id and timestamp and signatures):
  return False

      # Reject replays: the timestamp is signed, so it cannot be altered
      if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
          return False

      signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
      digest = hmac.new(secret.encode("utf-8"), signed_content, hashlib.sha256).digest()
      expected = base64.b64encode(digest).decode()

      # The header is a space-separated list of "v1,<signature>" entries
      for entry in signatures.split(" "):
          version, _, signature = entry.partition(",")
          if version == "v1" and hmac.compare_digest(signature, expected):
              return True
      return False

  ```

  ```javascript Node theme={null}
  const crypto = require("crypto");

  const TOLERANCE_SECONDS = 5 * 60;

  function verifyBindbeeSignature(secret, headers, rawBody) {
    const msgId = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatures = headers["webhook-signature"];
    if (!msgId || !timestamp || !signatures) return false;

    // Reject replays: the timestamp is signed, so it cannot be altered
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

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

    // The header is a space-separated list of "v1,<signature>" entries
    return signatures.split(" ").some((entry) => {
      const [version, signature] = entry.split(",");
      return (
        version === "v1" &&
        signature.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
      );
    });
  }
  ```
</CodeGroup>

Bindbee sends one signature today. Loop over the list anyway, since the specification allows several.

## Verify the legacy signature

Every delivery on both versions also carries `X-BINDBEE-WEBHOOK-SIGNATURE`: an HMAC-SHA256 of the raw body alone, keyed with the same secret, encoded as URL-safe base64 with its `=` padding. Version 1 webhooks carry only this one.

It has no timestamp, so a captured request verifies indefinitely. Switch to `webhook-signature` once your webhook is on version 2.

<CodeGroup>
  ```python Python theme={null}
  import base64
  import hashlib
  import hmac

  def verify_legacy_signature(secret: str, received_signature: str, raw_body: bytes) -> bool:
  digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
  expected = base64.urlsafe_b64encode(digest).decode()
  return hmac.compare_digest(expected, received_signature)

  # received_signature = request.headers["X-Bindbee-Webhook-Signature"]

  ```

  ```javascript Node theme={null}
  const crypto = require("crypto");

  function verifyLegacySignature(secret, receivedSignature, rawBody) {
    // URL-safe base64 with padding. digest("base64url") drops the padding and never matches
    const expected = crypto
      .createHmac("sha256", secret)
      .update(rawBody)
      .digest("base64")
      .replace(/\+/g, "-")
      .replace(/\//g, "_");

    const a = Buffer.from(expected);
    const b = Buffer.from(receivedSignature);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // receivedSignature = req.headers["x-bindbee-webhook-signature"]
  ```

  ```ruby Ruby theme={null}
  require 'base64'
  require 'openssl'

  def verify_legacy_signature(secret, received_signature, raw_body)
    digest = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), secret, raw_body)
    expected = Base64.urlsafe_encode64(digest)
    ActiveSupport::SecurityUtils.secure_compare(expected, received_signature)
  end

  # In Rails: received_signature = request.headers['X-Bindbee-Webhook-Signature']
  #           raw_body = request.raw_post
  ```
</CodeGroup>

## Rotate the secret

To replace a secret that has leaked, click the refresh icon on the **Security signature** card, tick **I understand, will update the code base**, and click **Regenerate it**.

<Warning>
  The old secret stops working immediately, for every webhook in your
  organization on both versions. Deliveries fail verification until every
  handler has the new secret, so deploy it straight away.
</Warning>

## Frequently Asked Questions

<AccordionGroup>
  <Accordion title="Every request fails verification">
    Check the body first. A framework that parses JSON before your handler runs hands you a re-serialized body, and its bytes no longer match. Read the raw body: `request.get_data()` in Flask, `express.raw()` in Express, `request.raw_post` in Rails.

    Then check the secret. The Node library needs the `whsec_` form, and pasting the secret as it appears in the dashboard makes every signature fail.
  </Accordion>

  <Accordion title="Verification fails with a timestamp error">
    Your server's clock is more than five minutes from real time. Sync it with
    NTP. Retries resend the original timestamp, and all four attempts finish
    within about a minute, so a correct clock never rejects a retry.
  </Accordion>

  <Accordion title="Verification broke for every webhook at once">
    Someone regenerated the secret. Copy the new one from the **Security
    signature** card and deploy it.
  </Accordion>

  <Accordion title="The legacy signature matches in Python but not in Node">
    `digest("base64url")` strips the trailing `=`. Build the URL-safe string from standard base64, as the sample above does.
  </Accordion>
</AccordionGroup>

## Related

* [Webhook payload](/guides/webhooks/payload) - every header and body field
* [Delivery and retries](/guides/webhooks/delivery) - what to return once a request verifies
* [Migrate from version 1](/guides/webhooks/migrate-from-v1) - moving off the legacy signature
* [Standard Webhooks](https://www.standardwebhooks.com/) - the specification and its libraries for other languages


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.