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

  1. JSON.parse trước khi verify — body bị reformat (whitespace, key order) → HMAC khác → fail. Phải dùng raw body.
  2. Express body-parser global — middleware express.json() ăn raw trước khi đến route. Phải scope raw parser cho riêng webhook route.
  3. 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().
  4. 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.