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

# Verifying Webhook Signatures

> Check that a webhook really came from BundleUp, and that it isn’t a replay.

Anyone who knows your webhook URL can send it a request. Before acting on a webhook, check that BundleUp sent it and that it isn’t an old message being replayed.

## How the signature works

Each webhook has its own **signing secret**, shown when you create it in **Settings → Webhooks**. BundleUp computes an HMAC-SHA256 of the raw request body using that secret, and sends it hex-encoded in the `BundleUp-Signature` header.

```mermaid theme={null}
flowchart TD
    A[Webhook request arrives] --> B[Read the raw body bytes]
    B --> C["HMAC-SHA256(raw body, signing secret)<br/>→ hex"]
    C --> D{"Equal to BundleUp-Signature?<br/>(constant-time compare)"}
    D -->|No| R1[Reject: 401]
    D -->|Yes| E[Parse the JSON]
    E --> F{"webhookTimestamp within<br/>60 s of now?"}
    F -->|No| R2[Reject: 401]
    F -->|Yes| OK[Accept: 200, then process]
```

1. Take the **raw** request body, exactly as received.
2. Compute HMAC-SHA256 over it with your signing secret, hex-encoded.
3. Compare it with `BundleUp-Signature` using a **constant-time** comparison.
4. Parse the JSON and check that `webhookTimestamp` (milliseconds) is within 60 seconds of your server’s clock.

<Warning>
  Use the raw body. If you parse the JSON and serialize it again, whitespace or key order can change and the signature won’t match.
</Warning>

## Examples

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  const crypto = require('node:crypto');
  const express = require('express');

  const SECRET = process.env.BUNDLEUP_WEBHOOK_SECRET;

  function isValidSignature(signature, rawBody) {
    if (typeof signature !== 'string') return false;

    const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest();
    const received = Buffer.from(signature, 'hex');

    return (
      received.length === expected.length &&
      crypto.timingSafeEqual(expected, received)
    );
  }

  const app = express();

  app.post(
    '/webhooks/bundleup',
    express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),
    (req, res) => {
      if (!isValidSignature(req.get('BundleUp-Signature'), req.rawBody)) {
        return res.sendStatus(401);
      }
      if (Math.abs(Date.now() - req.body.webhookTimestamp) > 60_000) {
        return res.sendStatus(401);
      }

      res.sendStatus(200);
      queue.add('bundleup-event', req.body); // process after responding
    },
  );
  ```

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

  from flask import Flask, abort, request

  SECRET = os.environ["BUNDLEUP_WEBHOOK_SECRET"].encode()
  app = Flask(__name__)

  @app.post("/webhooks/bundleup")
  def bundleup_webhook():
      raw_body = request.get_data()  # raw bytes, before parsing
      expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
      received = request.headers.get("BundleUp-Signature", "")

      if not hmac.compare_digest(expected, received):
          abort(401)

      event = request.get_json()
      if abs(time.time() * 1000 - event["webhookTimestamp"]) > 60_000:
          abort(401)

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

  ```ruby Ruby (Rails) theme={null}
  class BundleupWebhooksController < ActionController::API
    def create
      raw_body = request.raw_post
      expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['BUNDLEUP_WEBHOOK_SECRET'], raw_body)
      received = request.headers['BundleUp-Signature'].to_s

      return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, received)

      event = JSON.parse(raw_body)
      return head :unauthorized if ((Time.now.to_f * 1000) - event['webhookTimestamp']).abs > 60_000

      BundleupEventJob.perform_later(event)
      head :ok
    end
  end
  ```
</CodeGroup>

## Troubleshooting

| Symptom | Cause |
| - | - |
| Signature never matches | The body was parsed and re-serialized before hashing, or a proxy or framework rewrote it. Hash the raw bytes. |
| Signature never matches | You’re using a different webhook’s secret. Each webhook has its own. |
| Timestamp check fails on valid events | Your server’s clock is off. Sync it with NTP. Remember `webhookTimestamp` is in milliseconds. |

## Keeping the secret safe

Store the signing secret with your other server secrets, never in client code. If it leaks, create a new webhook with a new secret, switch your endpoint to it, and delete the old one.


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