Webhook là cửa vào hệ thống thanh toán của bạn. Nếu không verify chữ ký, bất kỳ ai biết URL endpoint đều có thể giả request {order_id, status: "paid"} → site bạn mark đơn paid mà không có tiền thật. Đây là lỗ hổng nghiêm trọng nhất khi tích hợp cổng thanh toán.
BeePay sign mọi webhook bằng HMAC-SHA256 với secret_key chỉ bạn có. Server bạn phải verify trước khi tin payload.
Cơ chế HMAC-SHA256
BeePay tính: signature = HMAC-SHA256(rawBody, SECRET_KEY) rồi gửi qua header X-Webhook-Signature: sha256=<hex>. Server bạn tính lại HMAC, compare bằng constant-time compare (chống timing attack).
Node.js / Express
import crypto from 'crypto'
import express from 'express'
const app = express()
// QUAN TRỌNG: dùng raw parser cho route này, không express.json()
app.post('/api/beepay-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-webhook-signature'] || ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.BEEPAY_WEBHOOK_SECRET)
.update(req.body) // raw Buffer, KHÔNG JSON.parse trước
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).json({ error: 'Invalid signature' })
}
const payload = JSON.parse(req.body.toString('utf8'))
// ... xử lý
}
)
Next.js App Router
export async function POST(req: Request) {
const raw = await req.text() // raw string
const sig = req.headers.get('x-webhook-signature') || ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.BEEPAY_WEBHOOK_SECRET!)
.update(raw)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return new Response('forbidden', { status: 401 })
}
const payload = JSON.parse(raw)
// ... xử lý
}
PHP / Laravel
$raw = $request->getContent();
$expected = 'sha256=' . hash_hmac('sha256', $raw, env('BEEPAY_WEBHOOK_SECRET'));
$sig = $request->header('X-Webhook-Signature', '');
if (!hash_equals($expected, $sig)) {
abort(401, 'Invalid signature');
}
$payload = json_decode($raw, true);
Python / FastAPI
import hmac, hashlib
from fastapi import Request, HTTPException
@app.post("/api/beepay-webhook")
async def webhook(req: Request):
raw = await req.body()
sig = req.headers.get("x-webhook-signature", "")
expected = "sha256=" + hmac.new(
SECRET.encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "Invalid signature")
payload = json.loads(raw)
4 pitfall thường gặp
- JSON.parse trước khi verify — body bị reformat (whitespace, key order) → HMAC khác → fail. Phải dùng raw body.
- Express body-parser global — middleware
express.json()ăn raw trước khi đến route. Phải scope raw parser cho riêng webhook route. - String compare thay vì timingSafeEqual — timing attack có thể leak signature từng byte một. Dùng
crypto.timingSafeEqual()/hash_equals()/hmac.compare_digest(). - Quên header prefix "sha256=" — BeePay gửi
sha256=<hex>, không phải hex thuần. Nếu compare hex với header chứa prefix → mismatch.
Idempotent + retry
BeePay retry webhook 3 lần (1 phút → 5 phút → 30 phút) nếu endpoint trả !2xx. Endpoint phải idempotent: cùng order_id gọi nhiều lần = same effect (check status hiện tại trước khi update). Trả 200 ngay cả khi đã paid (trả {ok:true, already_paid:true}) để BeePay không retry vô tận.