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

HeaderValue
X-BillPro-Signaturet=<unix timestamp>,v1=<hex signature>
X-BillPro-DeliveryThe 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

  1. Read the X-BillPro-Signature header. Split it on ,, then split each part on the first =. Take the t and v1 values. If either is missing, reject the request.
  2. Build the signed string: the t value, a . character, then the raw request body. For example 1767225600.{"id":"..."}.
  3. Compute an HMAC-SHA256 of the signed string, using the signing secret as the key. Encode the result as lowercase hex.
  4. Compare your result with v1 using a constant-time comparison.
  5. If they match, return a 2xx code and process the event. If they don't match, return 401. Don't parse, log or act on the body.
🚧

Use the raw body

Sign 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.

FrameworkRaw body
Expressexpress.raw({ type: 'application/json' })
Railsrequest.raw_post
Flaskrequest.get_data()
Djangorequest.body

Use a constant-time comparison, not ==. A normal string comparison can leak how much of the signature matched.

LanguageConstant-time comparison
Node.jscrypto.timingSafeEqual
RubyOpenSSL.secure_compare
Pythonhmac.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)
end
import 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

  1. Deploy your verification code to the endpoint URL.
  2. On the endpoint page, click Send test event.
  3. In Recent deliveries, check that the new order.test row shows Succeeded and a 2xx Response.

If it shows 401 in Response, see Troubleshooting in Handle webhook deliveries.


Recommended reading

Accept and process deliveries safely

Did this page help you?