> 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. **Bead uses two different signature schemes**, depending on which webhook you're receiving:

| Webhook type                       | Header                | Includes timestamp / replay protection |
| ---------------------------------- | --------------------- | -------------------------------------- |
| Payment and terminal webhooks      | `x-webhook-signature` | Yes                                    |
| Onboarding and settlement webhooks | `X-Bead-Signature`    | No                                     |

Use the section below that matches the webhook you're verifying. If you're not sure which one applies, check which page you configured the webhook from: [Payment Webhooks](/payments/payment-webhooks.md) or [Webhooks for Application Events](/onboarding/webhooks-for-application-events.md).

#### Other headers on every webhook request

Regardless of which signature scheme applies, every outbound webhook request — payment/terminal, onboarding, and settlement alike — 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 three webhook families — it's applied at the same layer as delivery itself, so it's consistent regardless of event type. If your firewall or WAF blocks requests that don't carry a `User-Agent` header, you can use this value to allowlist Bead's webhook traffic. See [Firewall & 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/test environment. If you're 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 payload for either scheme — HMAC signing covers the raw request body only, never headers — so this addition does not change or affect signature verification in either section below.

#### Payment and terminal webhooks — `x-webhook-signature`

Every webhook request from a payment or terminal-level webhook carries a cryptographic signature in the `x-webhook-signature` header. Verifying this signature ensures the payload was sent by Bead and was not altered in transit.

**1 — Header format**

```
x-webhook-signature: t=1781811428956,s=FK/SW9lIK0CXpNnfweTN3ZbJ8Nvbm1RF69Nm6XE8w3O=
```

| 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`, using the decoded bytes of the terminal's `signingSecret` |

**2 — Verification steps**

1. **Parse the header** and extract `t` and `s`.
2. **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.
3. **Decode the signing secret** from base64 to raw bytes before using it as the HMAC key.
4. **Construct the signed message** by concatenating `t`, a literal period, and the exact raw request body bytes: `message = t + "." + rawBody`.
5. **Compute the digest** using `HMAC-SHA256(key=decodedSecretBytes, message=message)` and base64-encode the result.
6. **Compare using constant-time equality** by checking your computed digest against `s`. Reject the request if they do not match.
7. **Only after both checks pass**, parse and process the JSON payload.

**3 — Code example (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;

app.post("/bead/webhooks", (req, res) => {
  const sigHeader = req.get("x-webhook-signature") || "";
  const parts = Object.fromEntries(sigHeader.split(",").map(p => p.split("=")));
  const tsPart = parts["t"];
  const sigPart = parts["s"];

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

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

  // Decode the signing secret from base64 to bytes
  const keyBytes = Buffer.from(SIGNING_SECRET, "base64");

  // Build the signed message: t + "." + rawBody
  const signedMessage = `${tsPart}.${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
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigPart))) {
    return res.status(400).send("Invalid signature");
  }

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

  res.sendStatus(200);
});

app.listen(3000);
```

**4 — Common pitfalls**

| Pitfall                                                           | Fix                                                                                                                     |
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Signing only `rawBody` instead of `t + "." + rawBody`             | The signed message must include the timestamp prefix. Concatenate `tsPart + "." + rawBody` before computing the digest. |
| Using the signing secret as a raw string instead of decoded bytes | `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.                |
| 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`. Use the one returned when you configured the webhook URL for that terminal.  |

**5 — Testing your implementation**

1. In sandbox, set the terminal webhook to your dev endpoint.
2. Trigger a test payment.
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.

#### Onboarding and settlement webhooks — `X-Bead-Signature`

Every webhook request from an onboarding or settlement webhook carries a cryptographic signature in the `X-Bead-Signature` header. This is a different scheme from the payment/terminal signature above — it uses a different header name, a different value format, and it does not include timestamp validation.

**1 — Header format**

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

The header value is a single lowercase hexadecimal string: the HMAC-SHA256 digest of the raw request body alone, computed using your `webhookSecret`. There is no timestamp component and no `t=`/`s=` structure.

**This scheme has no built-in replay protection.** Because there is no timestamp to validate, a captured request could in principle be replayed. If replay resistance matters for your integration, add your own protection at the application level — for example, tracking processed event `id` values and rejecting ones you have already seen, or enforcing your own acceptable-age window using the payload's `createdAt` field.

**2 — Verification steps**

1. **Capture the raw request body** exactly as received, before any JSON parsing.
2. **Read the `X-Bead-Signature` header.**
3. **Compute the expected digest** using `HMAC-SHA256(key=webhookSecret, message=rawBody)` and render it as lowercase hex.
4. **Compare using constant-time equality** by checking your computed digest against the header value. Reject the request if they do not match.
5. **Only after verification passes**, parse and process the JSON payload.

**3 — Code example (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 WEBHOOK_SECRET = process.env.BEAD_WEBHOOK_SECRET;

app.post("/bead/webhooks/onboarding", (req, res) => {
  const signatureHeader = req.get("x-bead-signature") || "";

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

  // Compute HMAC-SHA256 over the raw body, hex-encoded
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(req.rawBody)
    .digest("hex");

  // Constant-time compare
  const expectedBuf = Buffer.from(expected, "utf8");
  const receivedBuf = Buffer.from(signatureHeader, "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 — Common pitfalls**

| Pitfall                                          | Fix                                                                                                                          |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| Expecting a `t=...,s=...` structure              | `X-Bead-Signature` is a plain hex string, not a component-based header. Compare it directly, not by splitting on `,`.        |
| Producing a base64 digest instead of hex         | Use `.digest("hex")`, not `.digest("base64")`. A base64 digest compared to a hex value will always fail.                     |
| Including a timestamp in the signed message      | This scheme signs the raw body alone. Do not prepend a timestamp before computing the HMAC.                                  |
| Assuming timestamp/replay validation is built in | It is not. If your integration needs replay resistance, implement your own check using event `id` or `createdAt`.            |
| 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                           | Use the `webhookSecret` returned when you configured the webhook for that partner, not a payment terminal's `signingSecret`. |

**5 — Testing your implementation**

1. In sandbox, configure your partner-level onboarding webhook to point at your dev endpoint.
2. Submit a test onboarding application and progress it through signing, review, and boarding.
3. Log the received header, your computed digest, and the comparison result.
4. Tamper with the payload or the header to confirm your handler rejects invalid signatures.


---

# 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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
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=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
