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

Every webhook request 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=1752067200,s=9d6309b739a8e5b87e3c2b8d1d4fbc17e5e3c7f3c5b5a9d3e…
Component
Meaning

t

Unix-epoch timestamp (seconds) when Bead generated the event

s

HMAC-SHA256 hex digest of the raw request body, using the terminal’s signingSecret

2 — Verification steps

  1. Parse the header – extract t and s.

  2. Check timestamp drift – reject if the event is more than 5 minutes old (protects against replay).

  3. Compute your own digest

    expected = HMAC-SHA256(signingSecret, rawBody)
  4. Constant-time compare expected to the received s.

  5. Only if both checks pass, queue or process the payload.

3 — Code example (Node.js)

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

// Raw body needed for HMAC
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 [tsPart, sigPart] = sigHeader.split(",").map(p => p.split("=")[1]);
  const timestamp = Number(tsPart);
  const signature = sigPart;

  // ❶ Timestamp tolerance (5 min)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) {
    return res.status(400).send("Stale webhook");
  }

  // ❷ Compute expected signature
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(req.rawBody)
    .digest("hex");

  // ❸ Constant-time compare
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    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

Parsing JSON before capturing the raw body

Use middleware that gives you the unparsed req.rawBody.

Treating hex strings as UTF-8

Compare as byte buffers (timingSafeEqual).

Ignoring timestamp

Attackers could replay old events. Always enforce a skew limit.

Wrong secret

Each terminal has its own signingSecret. Use the one returned when you set the webhook URL.

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, calculated digest, and comparison result.

  4. Tamper with the payload or header to confirm your handler rejects invalid signatures.

Need help or sample code in another language? Email [email protected].

Last updated