Tạo trang QR thanh toán

Tích hợp QR chuyển khoản với webhook SePay để tự xác nhận đơn hàng ngay khi tiền vào tài khoản, kèm code mẫu PHP và Node.js sẵn dùng.

||

Khách đặt hàng → thấy QR → quét bằng app ngân hàng → chuyển khoản → SePay gửi webhook → server của bạn cập nhật đơn hàng → trang thanh toán tự chuyển sang Thành công. Toàn bộ luồng này xong trong 1 file HTML + 3 endpoint backend.

Yêu cầu
  • Liên kết tài khoản ngân hàng trên my.sepay.vn
  • Tạo webhook nhận giao dịch, xem Bắt đầu nhanh
  • Cấu hình mã thanh toán tại Công ty → Cấu hình chung → Cấu trúc mã thanh toán

Luồng hoạt động

Luồng thanh toán QR + Webhook
Rendering diagram...

URL tạo QR

SePay sinh ảnh QR qua endpoint qr.sepay.vn/img. App ngân hàng quét mã sẽ điền sẵn số tài khoản, số tiền, nội dung.

Code
1
https://qr.sepay.vn/img?acc={SO_TK}&bank={NGAN_HANG}&amount={TIEN}&des={NOI_DUNG}
accstringrequired
Số tài khoản thụ hưởng
bankstringrequired
Mã ngắn ngân hàng. Danh sách: qr.sepay.vn/banks.json
amountinteger
Số tiền (VND)
desstring
Nội dung chuyển khoản (URL-encode)

Ví dụ:

Code
1
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=DH12345
QR code thanh toán mẫu
Nhấn để phóng to
Mã QR chuyển khoản với số tiền và nội dung điền sẵn

Chi tiết đầy đủ: Tạo và nhúng QR Code.

Frontend: Trang thanh toán

Frontend hiển thị QR kèm bộ đếm ngược. Mỗi 3 giây, frontend gọi API kiểm tra trạng thái đơn. Khi hết 15 phút mà chưa thanh toán thì đánh dấu hết hạn.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Thanh toán đơn hàng</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; background: #f5f5f5;
min-height: 100dvh; margin: 0; display: grid; place-items: center; padding: 1rem; }
.card { width: min(100%, 28rem); background: #fff; border-radius: 1rem;
overflow: hidden; box-shadow: 0 4px 24px rgb(0 0 0 / 0.08); }
.card header { background: #1a56db; color: #fff; padding: 1.25rem; text-align: center; }
.card header strong { display: block; font-size: 1.75rem; }
.card main { padding: 1.5rem; display: grid; gap: 1rem; }
.qr { width: 15rem; height: 15rem; margin: 0 auto; }
dl { background: #f8fafc; border-radius: .5rem; padding: 1rem; margin: 0;
display: grid; grid-template-columns: auto 1fr; gap: .5rem 1rem; font-size: .9rem; }
dt { color: #6b7280; } dd { margin: 0; font-weight: 600; text-align: right; }
.status { padding: .75rem; border-radius: .5rem; text-align: center; font-weight: 600; }
.status[data-state="waiting"] { background: #fef3c7; color: #92400e; }
.status[data-state="paid"] { background: #d1fae5; color: #065f46; }
.status[data-state="expired"] { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<article class="card">
<header>
<div>Thanh toán đơn hàng</div>
<strong id="amount"></strong>
</header>
<main>
<img id="qr" class="qr" alt="QR code thanh toán">
<p>Mở app ngân hàng → Quét QR → Xác nhận</p>
<dl>
<dt>Ngân hàng</dt> <dd id="bank"></dd>
<dt>Số tài khoản</dt> <dd id="account"></dd>
<dt>Nội dung</dt> <dd id="content"></dd>
</dl>
<div id="status" class="status" data-state="waiting"></div>
</main>
</article>
 
<script type="module">
const ORDER = {
code: 'DH12345', amount: 100000,
bank: 'Vietcombank', accountNumber: '0010000000355',
};
 
const vnd = new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' });
const $ = (id) => document.getElementById(id);
 
// Điền thông tin + QR
$('amount').textContent = vnd.format(ORDER.amount);
$('bank').textContent = ORDER.bank;
$('account').textContent = ORDER.accountNumber;
$('content').textContent = ORDER.code;
$('qr').src = `https://qr.sepay.vn/img?${new URLSearchParams({
acc: ORDER.accountNumber, bank: ORDER.bank,
amount: ORDER.amount, des: ORDER.code,
})}`;
 
// Poll trạng thái đơn hàng
const deadline = Date.now() + 15 * 60_000;
const status = $('status');
 
async function tick() {
const left = deadline - Date.now();
if (left <= 0) {
status.dataset.state = 'expired';
status.textContent = 'Đơn hàng đã hết hạn';
return;
}
 
const mm = String(Math.floor(left / 60_000)).padStart(2, '0');
const ss = String(Math.floor((left % 60_000) / 1000)).padStart(2, '0');
 
try {
const res = await fetch(`/api/orders/${ORDER.code}/status`);
const data = await res.json();
if (data.status === 'paid') {
status.dataset.state = 'paid';
status.textContent = 'Thanh toán thành công';
return;
}
} catch {}
 
status.textContent = `Đang chờ thanh toán · ${mm}:${ss}`;
setTimeout(tick, 3000);
}
tick();
</script>
</body>
</html>
Nên dùng Polling hay Server-Sent Events?

Polling 3 giây/lần đơn giản, đủ nhanh cho UX thanh toán. Nếu dùng cùng backend cho nhiều đơn hàng, cân nhắc SSE (EventSource) hoặc WebSocket để server đẩy sự kiện thay vì client hỏi. Khi đó webhook handler chỉ cần pubsub.publish(code, 'paid') sau khi UPDATE.

Backend: 3 endpoint

  1. POST /api/orders: tạo đơn, trả mã + QR URL.
  2. GET /api/orders/:code/status: frontend poll.
  3. POST /webhook/sepay: nhận webhook từ SePay.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<?php
// db.php
$pdo = new PDO('mysql:host=localhost;dbname=myshop;charset=utf8mb4', 'user', 'pass',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
 
// --- POST /api/orders ---------------------------------------------
require 'db.php';
header('Content-Type: application/json');
 
$code = 'DH' . bin2hex(random_bytes(6));
$amount = (int) ($_POST['amount'] ?? 100000);
 
$pdo->prepare('INSERT INTO orders (code, amount, status) VALUES (?, ?, \'pending\')')
->execute([$code, $amount]);
 
echo json_encode([
'code' => $code,
'amount' => $amount,
'bank' => 'Vietcombank',
'accountNumber' => '0010000000355',
'qrUrl' => 'https://qr.sepay.vn/img?' . http_build_query([
'acc' => '0010000000355', 'bank' => 'Vietcombank',
'amount' => $amount, 'des' => $code,
]),
]);
 
 
// --- GET /api/orders/:code/status ---------------------------------
require 'db.php';
header('Content-Type: application/json');
 
$stmt = $pdo->prepare('SELECT status FROM orders WHERE code = ?');
$stmt->execute([$_GET['code'] ?? '']);
echo json_encode(['status' => $stmt->fetchColumn() ?: 'not_found']);
 
 
// --- POST /webhook/sepay ------------------------------------------
require 'db.php';
header('Content-Type: application/json');
 
// 1. Xác thực API Key (constant-time)
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!hash_equals('Apikey ' . getenv('SEPAY_API_KEY'), $auth)) {
http_response_code(401);
echo json_encode(['success' => false]);
exit;
}
 
$body = file_get_contents('php://input');
$data = json_decode($body, true);
 
// 2. Chống trùng: UNIQUE(transaction_id) + INSERT IGNORE (race-safe)
$log = $pdo->prepare('INSERT IGNORE INTO webhook_logs (transaction_id, body) VALUES (?, ?)');
$log->execute([$data['id'], $body]);
if ($log->rowCount() === 0) {
echo json_encode(['success' => true]); // đã xử lý trước đó
exit;
}
 
// 3. UPDATE atomic: chỉ đổi pending → paid nếu tiền đủ
if ($data['transferType'] === 'in' && !empty($data['code'])) {
$pdo->prepare(
'UPDATE orders SET status = \'paid\', paid_at = NOW()
WHERE code = ? AND status = \'pending\' AND amount <= ?'
)->execute([$data['code'], $data['transferAmount']]);
 
// TODO: enqueue email / cập nhật kho
}
 
echo json_encode(['success' => true]);
UPDATE atomic thay cho SELECT-then-UPDATE

UPDATE ... WHERE status = 'pending' AND amount <= ? chạy trong một câu lệnh, không cần transaction. Nếu 2 webhook đến cùng lúc (retry), chỉ lần đầu đổi được pending → paid, lần sau điều kiện status = 'pending' không còn khớp nên không làm gì. Đảm bảo không cộng tiền/gửi email 2 lần.

Database schema

SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE COMMENT 'Mã thanh toán (nội dung CK)',
amount BIGINT NOT NULL COMMENT 'Số tiền VND',
status ENUM('pending', 'paid', 'expired', 'cancelled') NOT NULL DEFAULT 'pending',
paid_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
CREATE TABLE webhook_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
transaction_id BIGINT NOT NULL UNIQUE COMMENT 'ID giao dịch SePay',
body JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Vì sao chọn kiểu này:

  • amount BIGINT: VND không có phần thập phân. INT max 2.1 tỷ, hoá đơn B2B dễ tràn.
  • code UNIQUE: chặn trùng mã thanh toán ở tầng DB.
  • transaction_id UNIQUE: khoá chống trùng webhook. Cùng với INSERT IGNORE trong handler, không cần lock.
  • body JSON: query được theo field với JSON_EXTRACT khi debug.
  • idx_status_created (status, created_at): query "đơn pending cũ hơn X phút để expire".

Checklist production

Bảo mật mã thanh toán

Mã thanh toán phải khó đoán. Dùng bin2hex(random_bytes(6)) (PHP) hoặc crypto.randomBytes(6).toString('hex') (Node). Không dùng số tăng dần hoặc timestamp đơn thuần, kẻ xấu đoán được mã có thể gửi webhook giả hoặc claim đơn người khác.

Validate số tiền ở SQL, không ở code

Đặt amount <= ? trong WHERE của câu UPDATE. Check ở PHP/Node có thể bị race: hai webhook cùng lúc, cả hai đều thấy status='pending', cả hai đều update.

Bật HMAC-SHA256 khi production

API Key chỉ xác minh request đến từ SePay, nhưng không bảo vệ payload nếu có ai chen ngang sửa đổi giữa đường truyền. Chuyển sang HMAC-SHA256 khi lên prod.

Trả 200 trước, xử lý nặng sau

Webhook có timeout 30 giây. Gửi email, cập nhật kho, gọi API bên thứ ba: đẩy vào queue (Redis, SQS, rabbitmq) rồi trả {"success": true} ngay.

Tiếp theo