Optional analytics on public pages. No session recording. Support works either way. Privacy details
Webhooks
HMAC over timestamp and raw body. Never verify parsed JSON.
import { createHmac, timingSafeEqual } from "crypto";
// rawBody must be the exact bytes received (Buffer), not re-serialized JSON.
export function verifyBospaySignature({ secret, timestamp, signature, rawBody }) {
if (!timestamp || !/^\d+$/.test(timestamp) || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false;
const expected = Buffer.from(
"sha256=" +
createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex"),
);
// During a secret rotation the header holds one value per valid secret.
return signature.split(",").some((candidate) => {
const provided = Buffer.from(candidate.trim());
return provided.length === expected.length && timingSafeEqual(provided, expected);
});
}| Header | Meaning |
|---|---|
| x-bospay-signature | sha256=<hex> HMAC-SHA256 of "{timestamp}.{rawBody}". Several comma-separated values during a rotation. |
| x-bospay-timestamp | Unix seconds used in the signature. Reject if more than 5 minutes off. |
| x-bospay-event-id | Event id. The same for every retry and resend. Deduplicate on it. |
| x-bospay-event / x-bospay-event-type | Event type. |
| x-bospay-delivery-id | This delivery attempt's record. |
| x-bospay-attempt | Attempt number, from 1. |
Headers other than the signature and timestamp are unsigned hints. Take business fields from the verified body.
POST /webhooks/:id/rotate-secret with expiresPreviousSecretInHours (up to 168) returns a new secret and keeps signing with the old one too until the grace period ends. Deploy the new secret, then let the old one expire. Your verifier must accept any matching value in the comma-separated header.
const response = await fetch(`${process.env.BOSPAY_API_BASE}/webhooks/we_example/rotate-secret`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"BOSPay-Version": "2026-09-23",
"X-API-Key": process.env.BOSPAY_API_KEY,
},
body: JSON.stringify({
"expiresPreviousSecretInHours": 24
})
});
if (!response.ok) {
throw new Error(`BOSPay request failed: ${response.status}`);
}
const data = await response.json();