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
Client gọi API tạo đơn hàng, nhận mã QR động Napas 247 và nội dung BVOIZ xxxxxx.
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.
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.
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:
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.
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.
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ư:
{
"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ệu | Kiểu | Bắt buộc | Mô tả chi tiết |
|---|---|---|---|
| id | Integer | Có | ID duy nhất của bản ghi biến động số dư từ cổng thanh toán. |
| gateway | String | Có | Tên ngân hàng thụ hưởng (ví dụ: MBBank, Vietcombank, ACB). |
| transactionDate | String | Có | Thời gian ghi nhận giao dịch từ ngân hàng (định dạng YYYY-MM-DD HH:mm:ss). |
| accountNumber | String | Có | Số tài khoản ngân hàng thụ hưởng nhận tiền của Bvoiz. |
| content | String | Có | Nội dung chuyển khoản thực tế của khách hàng (chứa mã đơn BVOIZ xxxxxx). |
| transferType | String | Có | Loại giao dịch: in (tiền vào) hoặc out (tiền ra). Webhook chỉ xử lý in. |
| transferAmount | Integer | Có | Số tiền chuyển khoản nhận được tính theo Việt Nam Đồng (VND). |
| referenceCode | String | Có | 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_code | String | Tùy chọn | Mã đơ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
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>.
https://img.vietqr.io/image/<BANK_BIN>-<ACCOUNT_NO>-compact2.png?amount=<AMOUNT>&addInfo=<MEMO>&accountName=<ACCOUNT_NAME>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:
https://api.bvoiz.com/api/payment/mock-payEndpoint 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.
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ệ.