Webhook & Thanh toán VietQR

Hướng dẫn thiết lập Webhook callback, tiếp nhận thông báo biến động giao dịch chuyển khoản VietQR Napas 247 và thử nghiệm thanh toán với Mock-Pay.

1. Tổng quan về Webhook & Luồng thanh toán VietQR

Trong kiến trúc thanh toán hiện đại của Bvoiz, Webhook (hay còn gọi là HTTP Callback / Instant Payment Notification - IPN) là kênh liên lạc thời gian thực một chiều. Ngay khi khách hàng quét mã VietQR và ngân hàng gửi giao dịch qua mạng lưới Napas 247, hệ thống tự động ghi nhận và đẩy bản tin thông báo (push event) tới server của bạn mà không cần client phải liên tục gửi request thăm dò (polling).

Quy trình xử lý giao dịch tự động

1
Tạo đơn hàng

Client gọi API tạo đơn hàng, nhận mã QR động Napas 247 và nội dung BVOIZ xxxxxx.

2
Quét mã chuyển khoản

Người dùng mở App ngân hàng quét mã QR. Napas 247 điều chuyển tiền tức thì 24/7.

3
SePay IPN Webhook

Ngân hàng báo có số dư, cổng SePay bắt biến động và kích hoạt Webhook đến Bvoiz.

4
Kích hoạt & Cộng Credits

Bvoiz tự động kiểm tra số tiền, khớp mã đơn và cộng credits ngay trong 3-10 giây.

2. Cấu hình Webhook Callback URL

Để tiếp nhận dữ liệu thanh toán từ Bvoiz hoặc cổng thanh toán SePay, máy chủ của bạn cần cung cấp một endpoint công khai (Public Endpoint) đáp ứng các tiêu chuẩn kỹ thuật sau:

Bảo mật HTTPS bắt buộc

URL Webhook phải dùng giao thức https:// có chứng chỉ SSL/TLS hợp lệ nhằm mã hóa toàn bộ dữ liệu giao dịch trên đường truyền.

Thời gian phản hồi (Response Time)

Endpoint phải trả về mã trạng thái 200 OK trong vòng 5 giây. Tránh xử lý các tác vụ đồng bộ nặng trước khi phản hồi webhook.

Xác thực tính toàn vẹn với Secret Key:

Mỗi request gửi tới Webhook URL sẽ đính kèm khóa bí mật thông qua HTTP Header:X-Secret-Key: <YOUR_SECRET_KEY>hoặcAuthorization: Apikey <YOUR_SECRET_KEY>. Hãy luôn xác thực header này trước khi xử lý đơn hàng để ngăn chặn các request giả mạo.

3. Định dạng dữ liệu Webhook Ngân hàng (Bank IPN Payload)

Khi giao dịch phát sinh trên tài khoản ngân hàng, hệ thống cổng thanh toán sẽ gửi một HTTP POST request với body định dạng JSON chứa chi tiết biến động số dư:

Mẫu Webhook Payload (JSON)
{
  "id": 8921450,
  "gateway": "MBBank",
  "transactionDate": "2026-09-28 14:22:10",
  "accountNumber": "0389468260",
  "subAccount": null,
  "code": "FT2627192840",
  "content": "BVOIZ 492015 CHUYEN TIEN NANG CAP GOI",
  "transferType": "in",
  "transferAmount": 249000,
  "amount": 249000,
  "accumulated": 15850000,
  "referenceCode": "NAPAS247_928194821",
  "reference": "928194821",
  "description": "MBBank chuyen tien nhanh Napas 247 den Bvoiz",
  "order_code": "BVOIZ492015",
  "status": "PAID"
}

Chi tiết các trường dữ liệu (Schema Reference)

Trường dữ liệuKiểuBắt buộcMô tả chi tiết
idIntegerCóID duy nhất của bản ghi biến động số dư từ cổng thanh toán.
gatewayStringCóTên ngân hàng thụ hưởng (ví dụ: MBBank, Vietcombank, ACB).
transactionDateStringCóThời gian ghi nhận giao dịch từ ngân hàng (định dạng YYYY-MM-DD HH:mm:ss).
accountNumberStringCóSố tài khoản ngân hàng thụ hưởng nhận tiền của Bvoiz.
contentStringCóNội dung chuyển khoản thực tế của khách hàng (chứa mã đơn BVOIZ xxxxxx).
transferTypeStringCóLoại giao dịch: in (tiền vào) hoặc out (tiền ra). Webhook chỉ xử lý in.
transferAmountIntegerCóSố tiền chuyển khoản nhận được tính theo Việt Nam Đồng (VND).
referenceCodeStringCóMã tham chiếu đối soát duy nhất từ mạng Napas 247 hoặc ngân hàng. Dùng để tránh cộng tiền trùng lặp (Idempotency).
order_codeStringTùy chọnMã đơn hàng Bvoiz (nếu cổng thanh toán tự động bóc tách được từ nội dung).

Mã nguồn mẫu tiếp nhận & xử lý Webhook

Mẫu triển khai Webhook Consumer
import express, { Request, Response } from "express";
import crypto from "crypto";

const app = express();
app.use(express.json());

const SEPAY_SECRET_KEY = process.env.SEPAY_SECRET_KEY || "YOUR_SECRET_KEY";

app.post("/api/webhooks/bank-payment", async (req: Request, res: Response) => {
  try {
    // 1. Kiểm tra secret key bảo mật từ Header
    const receivedKey = req.headers["x-secret-key"] as string || 
      (req.headers.authorization?.startsWith("Apikey ") 
        ? req.headers.authorization.replace("Apikey ", "") 
        : "");

    if (!receivedKey || !crypto.timingSafeEqual(Buffer.from(receivedKey), Buffer.from(SEPAY_SECRET_KEY))) {
      return res.status(401).json({
        success: false,
        message: "Unauthorized: Chữ ký bảo mật không hợp lệ",
      });
    }

    const { content, transferAmount, referenceCode, order_code } = req.body;

    // 2. Trích xuất mã đơn hàng từ nội dung nếu chưa có
    let finalOrderCode = order_code;
    if (!finalOrderCode && content) {
      const match = content.match(/BVOIZ\s*(\d+)/i);
      if (match) {
        finalOrderCode = `BVOIZ${match[1]}`;
      }
    }

    if (!finalOrderCode) {
      console.warn("Không tìm thấy mã đơn hàng BVOIZ trong nội dung:", content);
      return res.status(200).json({ success: false, message: "Ignored: Không phải mã đơn BVOIZ" });
    }

    // 3. Đảm bảo tính lũy đẳng (Idempotency) dựa trên referenceCode
    const isProcessed = await checkTransactionProcessed(referenceCode);
    if (isProcessed) {
      return res.status(200).json({ success: true, message: "Giao dịch đã được xử lý trước đó" });
    }

    // 4. Kích hoạt đơn hàng và cộng credits cho người dùng
    await activateUserSubscription(finalOrderCode, transferAmount, referenceCode);

    // 5. Trả về HTTP 200 OK ngay lập tức trong vòng < 3 giây
    return res.status(200).json({
      status: 200,
      success: true,
      order_code: finalOrderCode,
      message: "Kích hoạt dịch vụ thành công",
    });
  } catch (err: any) {
    console.error("Lỗi xử lý webhook:", err);
    return res.status(500).json({ error: "Lỗi nội bộ máy chủ khi xử lý webhook" });
  }
});

app.listen(3000, () => console.log("Webhook server đang lắng nghe cổng 3000"));

4. Tích hợp thanh toán VietQR Napas 247

Bvoiz áp dụng tiêu chuẩn mã hóa quốc gia VietQR EMVCo cho mọi đơn hàng. Khi người dùng tạo đơn hàng, hệ thống tự động sinh một mã QR động chứa sẵn toàn bộ thông tin thanh toán, bao gồm:

  • Mã định danh ngân hàng (Bank BIN): Mã số 6 chữ số theo tiêu chuẩn Ngân hàng Nhà nước (ví dụ MBBank là 970422, Vietcombank là 970436).
  • Số tài khoản thụ hưởng: Tài khoản doanh nghiệp chính thức của Bvoiz.
  • Số tiền chính xác: Số tiền tương ứng với gói cước đã chọn (không cần người dùng nhập tay).
  • Nội dung chuyển khoản (Memo): Định dạng bắt buộc BVOIZ <MÃ_ĐƠN_HÀNG>.
Cấu trúc URL sinh mã QR nhanh VietQR
https://img.vietqr.io/image/<BANK_BIN>-<ACCOUNT_NO>-compact2.png?amount=<AMOUNT>&addInfo=<MEMO>&accountName=<ACCOUNT_NAME>
Lưu ý về quy tắc bóc tách nội dung: Máy chủ Bvoiz sử dụng biểu thức chính quy (Regex) không phân biệt hoa thường để quét mã đơn hàng:const regex = /(?:BVOIZ|BVOIZ-)\s*(\d{5,8})/i;Vì vậy, người dùng chỉ cần giữ nguyên từ khóa BVOIZ xxxxxx trong nội dung chuyển khoản là hệ thống sẽ khớp đơn ngay tức thì.

5. Thử nghiệm thanh toán với Mock-Pay Endpoint

Trong quá trình phát triển (Local Development) hoặc kiểm thử tích hợp (Staging/CI/CD), bạn không cần thực hiện chuyển khoản tiền thật. Bvoiz cung cấp endpoint giả lập Mock-Pay để mô phỏng sự kiện ngân hàng báo có thành công:

POSThttps://api.bvoiz.com/api/payment/mock-pay

Endpoint này sẽ chuyển trạng thái đơn hàng từ PENDING sang PAID và nạp số credits tương ứng vào tài khoản của bạn ngay lập tức.

Gọi Mock-Pay Endpoint
curl -X POST https://api.bvoiz.com/api/payment/mock-pay \
  -H "Content-Type: application/json" \
  -d '{
    "order_code": "BVOIZ492015"
  }'

Phản hồi mẫu thành công (HTTP 200 OK):

{
  "status": 1,
  "result": {
    "message": "Xác nhận thanh toán đơn hàng thành công!",
    "order": {
      "id": "ord_89f029a1b8c4",
      "order_code": "BVOIZ492015",
      "plan_id": "advanced",
      "billing_cycle": "monthly",
      "amount": 249000,
      "credits": 600000,
      "status": "PAID",
      "bank_name": "MBBank",
      "account_no": "0389468260",
      "memo": "BVOIZ 492015",
      "paid_at": "2026-09-28T14:22:12.845Z"
    }
  }
}

6. Thực tiễn tốt nhất khi triển khai (Best Practices)

1. Đảm bảo tính lũy đẳng (Idempotency)

Trong trường hợp kết nối mạng không ổn định, cổng thanh toán có thể gửi lại cùng một Webhook nhiều lần (Retry Policy). Hãy luôn lưu referenceCode hoặc id giao dịch vào CSDL và kiểm tra xem giao dịch đã được xử lý chưa trước khi cộng credits.

2. Phản hồi nhanh (Fast Acknowledgment)

Trả về phản hồi HTTP 200 OK cho Webhook ngay khi xác thực header và lưu bản ghi vào hàng đợi (Message Queue: Redis / RabbitMQ / BullMQ). Không thực hiện các tác vụ tốn thời gian (như gửi email, push notification, tạo PDF) trực tiếp trong luồng nhận Webhook.

3. Cơ chế dự phòng Polling (Fallback Strategy)

Mặc dù Webhook có độ tin cậy trên 99.8%, phía giao diện người dùng (Frontend) vẫn nên thiết lập một hàm kiểm tra định kỳ mỗi 5 giây qua endpoint:GET /api/payment/check-status/:orderCodeđể đảm bảo giao diện chuyển đổi mượt mà ngay cả khi Webhook đến chậm hơn thông lệ.

Tài liệu này có giải đáp được thắc mắc của bạn không?