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

# Verify signatures

> Confirm that every webhook request came from Blujay and wasn't changed or replayed.

Blujay signs every webhook with your subscription's signing secret, following the [Standard Webhooks](https://www.standardwebhooks.com) specification. Verify the signature before you trust a request.

## The headers

| Header | Example | Meaning |
| - | - | - |
| `webhook-id` | `evt_abc123` | The event ID. Same as `event_id` in the body. |
| `webhook-timestamp` | `1792000000` | Unix time in seconds when this attempt was signed. |
| `webhook-signature` | `v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=` | One or more space-separated signatures. |

## How the signature is made

1. Take the secret after the `whsec_` prefix and base64-decode it. Those bytes are the HMAC key.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Compute HMAC-SHA256 of that content and base64-encode it.
4. The header value is `v1,` followed by that string.

To verify, compute the same value and compare it with each `v1,` entry in the header using a constant-time comparison. Also reject a `webhook-timestamp` more than five minutes away from your clock, so an old request can't be replayed.

## The four details that must be exact

| Detail | Get it right |
| - | - |
| **Raw body bytes** | Sign exactly the bytes Blujay sent. Read the body before any JSON middleware parses it. Parsing and re-serializing changes whitespace and key order, and the signature stops matching. |
| **Constant-time compare** | Compare the computed and given signatures with a constant-time function (`crypto.timingSafeEqual`, `hmac.compare_digest`). A plain `==` leaks timing information. |
| **Timestamp tolerance** | Reject a `webhook-timestamp` more than 5 minutes from your clock, in either direction. Keep your server on NTP. |
| **The key** | Strip `whsec_`, then base64-decode what's left. The decoded bytes are the HMAC key, not the text of the secret. |

> [!WARNING]
>
> Use the **raw** request body, byte for byte. Parsing the JSON and serializing it again changes the bytes and breaks the signature.

## Code

```javascript Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = Buffer.from(process.env.BLUJAY_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const TOLERANCE_SECONDS = 5 * 60;

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

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

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

  return signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

app.post("/webhooks/blujay", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verify(req.headers, rawBody)) return res.status(400).send("invalid signature");

  const event = JSON.parse(rawBody);
  // Store the event, deduplicating by event.event_id, then return quickly.
  res.status(200).end();
});

app.listen(3000);
```

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

from flask import Flask, request

app = Flask(__name__)
SECRET = base64.b64decode(os.environ["BLUJAY_WEBHOOK_SECRET"].removeprefix("whsec_"))
TOLERANCE_SECONDS = 5 * 60


def verify(headers, raw_body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False
    try:
        if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
            return False
    except ValueError:
        return False

    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False


@app.post("/webhooks/blujay")
def blujay_webhook():
    raw_body = request.get_data()
    if not verify(request.headers, raw_body):
        return "invalid signature", 400
    event = request.get_json()
    # Store the event, deduplicating by event["event_id"], then return quickly.
    return "", 200
```

## Use a library

Because Blujay follows the Standard Webhooks specification, the Standard Webhooks libraries verify Blujay signatures unchanged. For example, with the `standardwebhooks` package for Node.js:

```javascript theme={null}
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.BLUJAY_WEBHOOK_SECRET);
const event = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
}); // throws if the signature or timestamp is invalid
```

Standard Webhooks libraries exist for many languages, including Python, Go, Ruby, PHP, Java and C#. See [standardwebhooks.com](https://www.standardwebhooks.com).

## Troubleshooting

| Symptom | Likely cause | Fix |
| - | - | - |
| Every signature fails | Your framework parsed the body before you read it. | Read the raw body (`express.raw`, `request.get_data()`) and sign those bytes. |
| Every signature fails | You used the secret text as the key. | Strip `whsec_` and base64-decode the rest; use the bytes. |
| Every signature fails | Wrong secret: another subscription's, or one copied with a missing character. | Compare the end of your secret with `signing_secret_hint` on `GET /v1/webhook-subscriptions/{subscription_id}`. If it doesn't match, create a new subscription. |
| Signatures fail only sometimes | Your server clock drifts past the 5-minute tolerance. | Use NTP. Log the difference between `webhook-timestamp` and your clock. |
| Signatures fail only under load | Your endpoint answered after 10 seconds, so Blujay retried with a new timestamp while you were still checking the old attempt. | Return `2xx` first, then do the work. |
| A redelivered event has a different signature | Expected. Every attempt is signed again with a new `webhook-timestamp`. | Nothing to fix. `webhook-id` and the body stay the same. |

## Limitations

* One signing scheme: symmetric HMAC-SHA256 as `v1`. There is no public-key signature.
* One secret per subscription, shown once. There is no endpoint to rotate it in place; create a new subscription to change it.
* No source IP range is published, so the signature is your only proof of origin.

## Next steps

> **[Events](/webhooks/events.md)**
>
> Every event type and its payload.

> **[Delivery and retries](/webhooks/delivery.md)**
>
> What happens when your endpoint is down.


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