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| Part | Description |
|---|---|
t | Unix timestamp (seconds) of when the webhook was signed. |
v1 | Hex-encoded HMAC-SHA256 digest of the signing payload. |
Verification algorithm
- Extract
tandv1from the header. - Build the signing payload:
${t}.${rawBody}— the timestamp, a literal dot, then the raw JSON request body (no parsing, no re-serialization). - Compute the expected signature:
HMAC-SHA256(secret, signingPayload)→ hex. - Constant-time compare the computed hex with
v1. Reject if they differ. - Replay check — reject if
tis more than 5 minutes from the current time. - Return
2xxto 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.