For the complete documentation index, see llms.txt. This page is also available as Markdown.

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 or Webhooks for Application Events.

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

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

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)

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.

Last updated