> 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?

Every webhook request from a 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.

Need help or sample code in another language? Email <developers@bead.xyz>.


---

# 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:

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

The question should be specific, self-contained, and written in natural language.
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.
