BchainPayBchainPay

Verify a webhook

Every BchainPay webhook carries an X-Webhook-Signature header signed with HMAC-SHA256 using your per-endpoint signing secret. Always verify the signature before acting on the payload.

Signature header format

X-Webhook-Signature: t=1714222091,v1=5a3e2b...f8c1
PartDescription
tUnix timestamp (seconds) of when the webhook was signed.
v1Hex-encoded HMAC-SHA256 digest of the signing payload.

Verification algorithm

  1. Extract t and v1 from the header.
  2. Build the signing payload: ${t}.${rawBody} — the timestamp, a literal dot, then the raw JSON request body (no parsing, no re-serialization).
  3. Compute the expected signature: HMAC-SHA256(secret, signingPayload) → hex.
  4. Constant-time compare the computed hex with v1. Reject if they differ.
  5. Replay check — reject if t is more than 5 minutes from the current time.
  6. Return 2xx to acknowledge receipt.

Code examples

import crypto from "node:crypto";

export function verifyWebhook(
rawBody: string,
header: string,
secret: string,
toleranceSec = 300,
): boolean {
const params = Object.fromEntries(
  header.split(",").map((p) => {
    const i = p.indexOf("=");
    return [p.slice(0, i), p.slice(i + 1)];
  }),
);
const t = Number(params.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

const expected = crypto
  .createHmac("sha256", secret)
  .update(`${t}.${rawBody}`)
  .digest("hex");

const a = Buffer.from(expected, "hex");
const b = Buffer.from(params.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Replay protection

The t field is a Unix timestamp of when the webhook was signed. Reject events where t is more than 5 minutes from your server's current time to prevent replay attacks.

Each retry carries a fresh signature with an updated t, so retried deliveries will pass the tolerance check.

What if I miss a webhook?

BchainPay retries failed deliveries (any non-2xx response) up to 5 times with exponential backoff (30 s × 2^attempt, capped at 1 hour). After all retries are exhausted the event is dead-lettered. Failed and dead-lettered deliveries are visible in the dashboard.

If your endpoint was down for an extended period or you need to reconcile state, poll the API directly:

curl https://api.bchainpay.com/v1/payment-intents/{id} \
  -H "Authorization: Bearer pk_live_..."

The API is the source of truth — webhooks are a delivery optimization, not a data store.

Last updated Edit on GitHub