Your webhook endpoint is a public URL, which means anyone can send a POST to it — including someone forging a fake "payment confirmed" event. Signature verification is how you tell a genuine Q8QPay notification from a forgery: only Q8QPay knows your webhook secret, so only Q8QPay can produce a signature that matches.
How Q8QPay signs a webhook
When an invoice confirms, Q8QPay sends your callback URL a JSON body and computes a signature over it:
- Algorithm: HMAC-SHA256.
- Key: your webhook secret (the live secret for live invoices, the test secret for test invoices).
- Message: the exact raw JSON body of the request.
- Encoding: lowercase hex.
- Header: the signature is sent in
X-Webhook-Signature. Q8QPay also sendsX-Webhook-IdandX-Invoice-Reference.
To verify, you recompute the HMAC over the body you received using your secret, then compare it to the header. If they match, the request is authentic.
The one rule that trips people up: use the raw body
You must compute the HMAC over the exact bytes you received, not over a re-serialized object. If your framework parses JSON into an object and you then JSON.stringify it again, key order or spacing can differ and the signature will not match. Capture the raw request body before any JSON parsing.
Node.js (Express)
const crypto = require('crypto');
function isValidSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(signature || '', 'hex');
const b = Buffer.from(expected, 'hex');
// Constant-time compare; lengths must match first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Register the RAW body parser for this route so req.body is a Buffer.
app.post('/webhooks/q8qpay',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Webhook-Signature');
const secret = process.env.Q8QPAY_WEBHOOK_SECRET;
if (!isValidSignature(req.body, signature, secret)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'invoice.confirmed') {
// Fulfil the order for event.invoice_reference
}
res.sendStatus(200);
});
Python (Flask)
import hmac
import hashlib
def is_valid_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature or '')
@app.route('/webhooks/q8qpay', methods=['POST'])
def q8qpay_webhook():
signature = request.headers.get('X-Webhook-Signature', '')
secret = os.environ['Q8QPAY_WEBHOOK_SECRET']
# request.get_data() returns the raw body bytes
if not is_valid_signature(request.get_data(), signature, secret):
return 'invalid signature', 401
event = request.get_json()
if event['type'] == 'invoice.confirmed':
pass # Fulfil the order for event['invoice_reference']
return '', 200
Checklist for a secure webhook handler
- Verify before you trust. Reject any request whose signature is missing or does not match — return 401 and do nothing else.
- Use a constant-time comparison (
crypto.timingSafeEqual,hmac.compare_digest), never==, to avoid timing attacks. - Use the right secret. Live invoices are signed with your live webhook secret, test invoices with your test secret. Keep both in environment variables, never in code.
- Treat delivery as at-least-once. Failed deliveries are retried with exponential backoff, so the same confirmation can arrive more than once — make your handler idempotent by keying on the invoice reference.
- Return 2xx only after you have safely recorded the event, so retries stop once you have truly received it.
What is in the payload
A confirmation webhook includes the invoice reference, status, asset code (network), payout address, exact amount, transaction hash, your metadata, and a timestamp. Because you have already verified the signature, you can trust those fields and fulfil the order — no need to call back to confirm.