Verify webhook signatures
Check that each webhook request came from BillPro and wasn't changed
BillPro signs every webhook request with your endpoint's signing secret. Check the signature before you read the body. Anyone can send a request to your URL, but only BillPro has the secret.
Before you start
- The endpoint's signing secret. It's
whsec_followed by 64 hexadecimal characters. See Set up a webhook endpoint. - Access to the raw request body in your web framework, before any JSON parsing.
Request headers
| Header | Value |
|---|---|
X-BillPro-Signature | t=<unix timestamp>,v1=<hex signature> |
X-BillPro-Delivery | The delivery ID. It stays the same on every retry and redelivery of an event. Use it to spot repeats. |
Header names are case-insensitive. Your framework may show them in lower case, for example x-billpro-signature.
Use the secret exactly as shown
Use the whole secret as the HMAC key, as a plain string. Keep the whsec_ prefix. Don't hex-decode or base64-decode it.
Each endpoint has its own secret. If you have several endpoints, give each one its own URL path, and check each request with the secret for that path.
Verify a request
- Read the
X-BillPro-Signatureheader. Split it on,, then split each part on the first=. Take thetandv1values. If either is missing, reject the request. - Build the signed string: the
tvalue, a.character, then the raw request body. For example1767225600.{"id":"..."}. - Compute an HMAC-SHA256 of the signed string, using the signing secret as the key. Encode the result as lowercase hex.
- Compare your result with
v1using a constant-time comparison. - If they match, return a
2xxcode and process the event. If they don't match, return401. Don't parse, log or act on the body.
Use the raw bodySign the body exactly as it arrived. If your framework parses the JSON first and you serialise it again, the bytes change and the signature won't match.
| Framework | Raw body |
|---|---|
| Express | express.raw({ type: 'application/json' }) |
| Rails | request.raw_post |
| Flask | request.get_data() |
| Django | request.body |
Use a constant-time comparison, not ==. A normal string comparison can leak how much of the signature matched.
| Language | Constant-time comparison |
|---|---|
| Node.js | crypto.timingSafeEqual |
| Ruby | OpenSSL.secure_compare |
| Python | hmac.compare_digest |
Code examples
Each function returns true when the signature is valid.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyBillProSignature(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(
(header ?? '').split(',').map((pair) => pair.split('=', 2))
)
if (!t || !v1) return false
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`, 'utf8')
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(v1, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}require 'openssl'
def verify_billpro_signature(raw_body, header, secret)
parts = header.to_s.split(',').map { |p| p.split('=', 2) }.to_h
t, v1 = parts.values_at('t', 'v1')
return false unless t && v1
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
OpenSSL.secure_compare(expected, v1)
endimport hashlib
import hmac
def verify_billpro_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)This Express route uses the Node.js function. It reads the raw body, checks the signature, and replies before it does any work.
import express from 'express'
import { verifyBillProSignature } from './verifyBillProSignature.js'
const app = express()
const secret = process.env.BILLPRO_WEBHOOK_SECRET // whsec_your_signing_secret
app.post(
'/billpro/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8')
const signature = req.get('x-billpro-signature')
if (!verifyBillProSignature(rawBody, signature, secret)) {
return res.sendStatus(401)
}
res.sendStatus(200)
const event = JSON.parse(rawBody)
const deliveryId = req.get('x-billpro-delivery')
// Queue the event for processing. See Handle webhook deliveries.
}
)Optional: check the timestamp
The t value is the time BillPro signed the request, in Unix seconds. You can reject requests where t is much older than the current time. This limits replays of a captured request.
BillPro signs every attempt when it sends it. Retries and redeliveries carry a fresh t and a new signature, over the same body. So a timestamp window doesn't reject valid retries.
If you add this check, allow for clock drift. Choose a window that suits your system.
Test your code
- Deploy your verification code to the endpoint URL.
- On the endpoint page, click Send test event.
- In Recent deliveries, check that the new
order.testrow showsSucceededand a2xxResponse.
If it shows 401 in Response, see Troubleshooting in Handle webhook deliveries.
Updated about 11 hours ago
Recommended reading
Accept and process deliveries safely