Skip to content

Verifying Signatures

Every webhook delivery is signed with the endpoint's signingSecret so you can prove the request came from BuildWorkPro and was not tampered with in transit. The format is Stripe-compatible: an HMAC-SHA256 of ${timestamp}.${rawBody}, encoded as hex.

Verify the signature on every delivery. An unsigned or wrong-signature request must be rejected.

Every POST carries the following headers:

| Header | Description | | -------------------------- | --------------------------------------------------------------------------------------------------------------- | | BuildWorkPro-Signature | Comma-separated tokens: t=<unix-seconds>,v1=<hex-hmac>. | | BuildWorkPro-Event | The event type, e.g. bid.accepted. Convenient for routing without parsing the body. | | BuildWorkPro-Event-Id | Unique event id (also present as id in the body). Use this for idempotency on your side. | | BuildWorkPro-Delivery-Id | Unique delivery id. Same event re-delivered (e.g., via replay) carries the same event id but a new delivery id. | | Content-Type | Always application/json. | | User-Agent | BuildWorkPro-Webhooks/1.0. |

The signature is computed over the concatenation ${timestamp}.${rawBody}, where:

  • timestamp is the value from the t= token in the BuildWorkPro-Signature header (unix seconds, UTC).
  • rawBody is the exact bytes of the request body, before any JSON parsing.
HMAC-SHA256( signing_secret , `${t}.${rawBody}` ) == hex( v1 )

BuildWorkPro accepts timestamps within ±5 minutes (300 seconds) of the receiving server's clock. Outside that window the verifier should reject the request even if the HMAC matches -- this is the replay-attack defense.

If your verifier consistently fails on timestamp skew, run NTP on your receiver. Don't widen the window.

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SIGNING_SECRET = process.env.BWP_WEBHOOK_SECRET;
const MAX_SKEW_SEC = 300;
const app = express();
// Mount express.raw on the webhook path so req.body is a Buffer of the raw bytes.
// Don't use express.json() before this route, or signature verification will fail.
app.post('/bwp', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(SIGNING_SECRET, req.body, req.header('BuildWorkPro-Signature'))) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Persist event.id for idempotency, then enqueue downstream work.
res.status(200).send('ok');
});
function verifySignature(secret, rawBody, header) {
if (!secret || !header) return false;
const t = Number(/(?:^|,)t=(\d+)/.exec(header)?.[1]);
const v1 = /(?:^|,)v1=([0-9a-f]+)/.exec(header)?.[1];
if (!Number.isFinite(t) || !v1) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > MAX_SKEW_SEC) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
app.listen(3000);

Network glitches and the retry schedule both mean the same event id can arrive more than once. Store the id from each event you process, and skip any event whose id you've already handled. The id is stable across retries and across replays.

If you suspect the secret has leaked, rotate it via POST /api/v1/webhook-endpoints/{id}/rotate-secret. The response carries the new secret (shown once). Rotation has a 24-hour overlap window: the previous secret stays valid for ~24 hours so your receiver can move to the new secret on its own schedule without dropping a single delivery.

During the overlap window, each delivery's BuildWorkPro-Signature header carries two v1= tokens -- one signed with the new secret and one with the previous secret (e.g. t=...,v1=<new>,v1=<previous>). A receiver that has already switched to the new secret matches the first token; a receiver still on the old secret matches the second. To verify robustly across a rotation, iterate every v1= token in the header and accept the delivery if any of them matches your current secret, rather than reading only the first. Once you've confirmed the new secret works, the old one expires automatically at the end of the window.