> For the complete documentation index, see [llms.txt](https://developers.bead.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.bead.xyz/faqs-and-troubleshooting/webhooks-and-error-codes/how-do-i-verify-that-a-webhook-really-came-from-bead.md).

# How do I verify that a webhook really came from Bead?

Bead signs every webhook request so you can confirm it was sent by Bead and was not altered in transit. **All Bead webhooks use one signature standard, the `X-Webhook-Signature` header.** The header format, the signed message, and the timestamp check are the same for every webhook family. The only difference is which secret you use and how you turn it into the HMAC key.

| Webhook family                                              | Where you configure it                              | Secret                            | HMAC key                                                                               |
| ----------------------------------------------------------- | --------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------- |
| Payment and terminal webhooks                               | [Payment Webhooks](/payments/payment-webhooks.md)   | `signingSecret`, one per terminal | The **base64 decoded** bytes of the `signingSecret`                                    |
| Partner webhooks (application events and settlement events) | [Partner Webhooks](/onboarding/partner-webhooks.md) | `webhookSecret`, one per partner  | The **UTF-8 bytes** of the `webhookSecret` exactly as issued. Do not base64 decode it. |

Partner webhooks also continue to send the legacy `X-Bead-Signature` header for integrations that already verify it. See [Legacy X-Bead-Signature header](#legacy-x-bead-signature-header) below.

#### Other headers on every webhook request

Every outbound webhook request, whether it is a payment, terminal, application, or settlement event, also includes:

| Header         | Value                             |
| -------------- | --------------------------------- |
| `Content-Type` | `application/json; charset=utf-8` |
| `User-Agent`   | `Bead-PaymentService/1.0`         |

The `User-Agent` value is fixed and shared across all webhook families. It is applied at the same layer as delivery itself, so it is consistent regardless of event type. If your firewall or WAF blocks requests that do not carry a `User-Agent` header, you can use this value to allowlist Bead's webhook traffic. See [Firewall and IP allowlisting for webhooks](/faqs-and-troubleshooting/webhooks-and-error-codes/firewall-and-ip-allowlisting-for-webhooks.md) for the full recommendation.

**Note:** as of this writing, `User-Agent: Bead-PaymentService/1.0` is confirmed in the Bead Sandbox and test environment. If you are allowlisting for a production endpoint, confirm with your Bead contact that the same header is present in production before relying on it there.

The `User-Agent` header is not part of the signed message. Signing covers the timestamp and the raw request body only, never other headers, so this header does not affect signature verification.

#### The `X-Webhook-Signature` header

**1. Header format**

```
X-Webhook-Signature: t=1781811428956,s=FK/SW9lIK0CXpNnfweTN3ZbJ8Nvbm1RF69Nm6XE8w3O=
```

Header names are not case sensitive, so your framework may present this as `x-webhook-signature`.

| Component | Meaning                                                                         |
| --------- | ------------------------------------------------------------------------------- |
| `t`       | Unix epoch timestamp in **milliseconds** when Bead generated the event          |
| `s`       | **Base64 encoded** HMAC-SHA256 digest of the signed message `t + "." + rawBody` |

**2. Verification steps**

1. **Capture the raw request body** exactly as received, before any JSON parsing.
2. **Parse the header** and extract `t` and `s`. Split each part on the first `=` only, because the base64 value in `s` ends with `=` padding.
3. **Validate the timestamp** by confirming `t` is within 5 minutes of the current time in milliseconds. Reject requests outside this window to prevent replay attacks.
4. **Build the HMAC key** for your webhook family. For payment and terminal webhooks, base64 decode the `signingSecret` to raw bytes. For partner webhooks, use the `webhookSecret` as UTF-8 bytes, exactly as issued, including any prefix.
5. **Construct the signed message** by concatenating `t`, a literal period, and the exact raw request body: `message = t + "." + rawBody`.
6. **Compute the digest** using `HMAC-SHA256(key, message)` and base64 encode the result.
7. **Compare using constant time equality** by checking your computed digest against `s`. Reject the request if they do not match.
8. **Only after both checks pass**, parse and process the JSON payload.

**3. Code example for payment and terminal webhooks (Node.js)**

```js
import crypto from "crypto";
import express from "express";
const app = express();

// Raw body must be captured before JSON parsing
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));

const SIGNING_SECRET = process.env.BEAD_SIGNING_SECRET;

// Split on the first "=" only: the base64 value in s ends with "=" padding
function parseSignatureHeader(header) {
  return Object.fromEntries(
    header.split(",").map((part) => {
      const i = part.indexOf("=");
      return [part.slice(0, i), part.slice(i + 1)];
    })
  );
}

app.post("/bead/webhooks", (req, res) => {
  const { t, s } = parseSignatureHeader(req.get("x-webhook-signature") || "");

  if (!t || !s) {
    return res.status(400).send("Missing signature header");
  }

  // Validate timestamp. t is in milliseconds
  if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) {
    return res.status(400).send("Stale webhook");
  }

  // Payment and terminal webhooks: decode the signing secret from base64 to bytes
  const keyBytes = Buffer.from(SIGNING_SECRET, "base64");

  // Signed message: t + "." + rawBody
  const signedMessage = `${t}.${req.rawBody.toString("utf8")}`;

  // Compute HMAC-SHA256 and base64 encode the digest
  const expected = crypto
    .createHmac("sha256", keyBytes)
    .update(signedMessage)
    .digest("base64");

  // Constant time compare
  const expectedBuf = Buffer.from(expected, "utf8");
  const receivedBuf = Buffer.from(s, "utf8");
  if (
    expectedBuf.length !== receivedBuf.length ||
    !crypto.timingSafeEqual(expectedBuf, receivedBuf)
  ) {
    return res.status(400).send("Invalid signature");
  }

  // Verified. Handle the event
  const event = req.body;
  // ...business logic...

  res.sendStatus(200);
});

app.listen(3000);
```

**4. Code example for partner webhooks (Node.js)**

The only change from the payment example is the key. A partner `webhookSecret` is used exactly as issued, as UTF-8 bytes, and is not base64 decoded.

```js
import crypto from "crypto";
import express from "express";
const app = express();

// Raw body must be captured before JSON parsing
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));

const WEBHOOK_SECRET = process.env.BEAD_WEBHOOK_SECRET;

// Split on the first "=" only: the base64 value in s ends with "=" padding
function parseSignatureHeader(header) {
  return Object.fromEntries(
    header.split(",").map((part) => {
      const i = part.indexOf("=");
      return [part.slice(0, i), part.slice(i + 1)];
    })
  );
}

app.post("/bead/webhooks/partner", (req, res) => {
  const { t, s } = parseSignatureHeader(req.get("x-webhook-signature") || "");

  if (!t || !s) {
    return res.status(400).send("Missing signature header");
  }

  // Validate timestamp. t is in milliseconds
  if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) {
    return res.status(400).send("Stale webhook");
  }

  // Partner webhooks: the key is the webhookSecret as UTF-8 bytes. Do not base64 decode it
  const keyBytes = Buffer.from(WEBHOOK_SECRET, "utf8");

  // Signed message: t + "." + rawBody
  const signedMessage = `${t}.${req.rawBody.toString("utf8")}`;

  const expected = crypto
    .createHmac("sha256", keyBytes)
    .update(signedMessage)
    .digest("base64");

  // Constant time compare
  const expectedBuf = Buffer.from(expected, "utf8");
  const receivedBuf = Buffer.from(s, "utf8");
  if (
    expectedBuf.length !== receivedBuf.length ||
    !crypto.timingSafeEqual(expectedBuf, receivedBuf)
  ) {
    return res.status(400).send("Invalid signature");
  }

  // Verified. Handle the event
  const event = req.body;
  // ...business logic...

  res.sendStatus(200);
});

app.listen(3000);
```

**5. Common pitfalls**

| Pitfall                                               | Fix                                                                                                                                        |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Signing only `rawBody` instead of `t + "." + rawBody` | The signed message must include the timestamp prefix. Concatenate `t + "." + rawBody` before computing the digest.                         |
| Base64 decoding a partner `webhookSecret`             | Partner secrets are used exactly as issued, as UTF-8 bytes. Only terminal `signingSecret` values are base64 decoded.                       |
| Using a terminal `signingSecret` as a raw string      | `signingSecret` is base64 encoded key material. Decode it first: `Buffer.from(SIGNING_SECRET, "base64")`.                                  |
| Producing a hex digest instead of base64              | Use `.digest("base64")`, not `.digest("hex")`. A hex digest compared to a base64 value will always fail.                                   |
| Losing the `=` padding when parsing the header        | Split each part on the first `=` only. A plain `split("=")` drops the trailing padding from `s` and every comparison fails.                |
| Treating `t` as seconds                               | `t` is in milliseconds. Compare using `Date.now()` directly without dividing.                                                              |
| Ignoring the timestamp                                | Always enforce a skew limit. Without it, captured requests can be replayed indefinitely.                                                   |
| Parsing JSON before capturing the raw body            | Use middleware that gives you the unparsed `req.rawBody` before any body parser runs.                                                      |
| Using the wrong secret                                | Each terminal has its own `signingSecret`. Each partner has one `webhookSecret`. Use the secret returned when you configured that webhook. |

**6. Testing your implementation**

1. In sandbox, point a terminal webhook or your partner webhook at your dev endpoint.
2. Trigger a test payment, or progress a test onboarding application.
3. Log the received header, your computed signed message, your calculated digest, and the comparison result.
4. Tamper with the payload or the header to confirm your handler rejects invalid signatures.

#### Legacy X-Bead-Signature header

Before partner webhooks adopted the standard above, they carried only the `X-Bead-Signature` header. **Bead still sends this header on every partner webhook, unchanged, so integrations that already verify it continue to work without any change.** It is considered legacy. New integrations should verify `X-Webhook-Signature`, and existing integrations should plan to move to it.

The legacy header is a single lowercase hexadecimal string: the HMAC-SHA256 digest of the raw request body alone, computed with your partner `webhookSecret` as UTF-8 bytes. There is no timestamp and no `t=` and `s=` structure.

```
X-Bead-Signature: 509e104358f2c035d120c2444e5dcfed7b1e3dd0062e6f0d628e2df29e22e0fa
```

**The legacy header has no built in replay protection.** Because there is no timestamp to validate, a captured request could in principle be replayed. If you keep verifying it, add your own protection, for example by tracking processed event identifiers or by enforcing your own acceptable age window using the payload's `createdAt` field. Verifying `X-Webhook-Signature` instead gives you timestamp validation without extra work.

To verify the legacy header:

1. Capture the raw request body exactly as received, before any JSON parsing.
2. Compute `HMAC-SHA256(key=webhookSecret, message=rawBody)` and render it as lowercase hex.
3. Compare it to the `X-Bead-Signature` header using constant time equality.

```js
const expected = crypto
  .createHmac("sha256", WEBHOOK_SECRET)
  .update(req.rawBody)
  .digest("hex");
```

Common legacy pitfalls: producing a base64 digest instead of hex, including a timestamp in the signed message, and parsing JSON before capturing the raw body.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://developers.bead.xyz/faqs-and-troubleshooting/webhooks-and-error-codes/how-do-i-verify-that-a-webhook-really-came-from-bead.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
