Tạo QR và Form thanh toán

Hướng dẫn tạo mã QR chuyển khoản ngân hàng và xây dựng form thanh toán tích hợp QR + Webhooks để tự động xác nhận thanh toán.


Bài viết này hướng dẫn bạn kết hợp QR Code chuyển khoản với SePay Webhooks để xây dựng luồng thanh toán tự động: khách hàng quét mã QR → chuyển khoản → hệ thống tự động xác nhận đơn hàng.

Trước khi bắt đầu

Đảm bảo bạn đã:

  • Liên kết tài khoản ngân hàng trên my.sepay.vn
  • Tạo webhook nhận thông báo 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

Tạo QR Code chuyển khoản

SePay cung cấp API tạo ảnh QR Code tại qr.sepay.vn. Khi khách hàng quét mã bằng app ngân hàng, toàn bộ thông tin chuyển khoản (ngân hàng, số tài khoản, số tiền, nội dung) sẽ được tự động điền sẵn.

Cấu trúc URL

Code
1
https://qr.sepay.vn/img?acc={SO_TAI_KHOAN}&bank={NGAN_HANG}&amount={SO_TIEN}&des={NOI_DUNG}
accstringrequired
Số tài khoản ngân hàng thụ hưởng
bankstringrequired
Tên ngắn của ngân hàng. Xem danh sách tại qr.sepay.vn/banks.json
amountinteger
Số tiền chuyển khoản (VND)
desstring
Nội dung chuyển khoản (URL-encoded)

Ví dụ

Code
1
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=DH12345
QR Code thanh toán
Nhấn để phóng to
QR Code thanh toán mẫu
QR Code động

Bạn có thể tạo QR động bằng cách thay đổi amountdes cho từng đơn hàng. Mỗi đơn hàng sẽ có một mã QR riêng với nội dung chuyển khoản chứa mã đơn hàng để SePay tự nhận diện.

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


Xây dựng Form thanh toán với QR

Dưới đây là hướng dẫn tạo form thanh toán đơn giản: hiển thị thông tin đơn hàng, ảnh QR và tự động cập nhật trạng thái khi khách hàng thanh toán.

Luồng hoạt động

  1. Khách hàng đặt hàng → hệ thống tạo đơn hàng với mã thanh toán duy nhất (ví dụ: DH12345)
  2. Trang thanh toán hiển thị QR Code chứa mã thanh toán trong nội dung chuyển khoản
  3. Khách hàng quét QR → chuyển khoản
  4. SePay nhận giao dịch → gửi webhook đến server của bạn với code = "DH12345"
  5. Server cập nhật đơn hàng thành Đã thanh toán
  6. Trang thanh toán tự động hiển thị Thanh toán thành công (polling hoặc WebSocket)

Frontend: Trang thanh toá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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thanh toán đơn hàng</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; }
.payment-container { max-width: 480px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }
.payment-header { background: #1a56db; color: #fff; padding: 20px; text-align: center; }
.payment-header h2 { font-size: 18px; margin-bottom: 4px; }
.payment-header .amount { font-size: 28px; font-weight: 700; }
.payment-body { padding: 24px; text-align: center; }
.qr-wrapper { margin: 16px 0; }
.qr-wrapper img { width: 240px; height: 240px; border-radius: 8px; }
.transfer-info { text-align: left; background: #f8fafc; border-radius: 8px; padding: 16px; margin: 16px 0; font-size: 14px; }
.transfer-info .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
.transfer-info .row:last-child { border-bottom: none; }
.transfer-info .label { color: #6b7280; }
.transfer-info .value { font-weight: 600; color: #111827; }
.status { margin-top: 16px; padding: 12px; border-radius: 8px; font-weight: 600; font-size: 15px; }
.status.waiting { background: #fef3c7; color: #92400e; }
.status.success { background: #d1fae5; color: #065f46; }
.countdown { color: #6b7280; font-size: 13px; margin-top: 8px; }
</style>
</head>
<body>
<div class="payment-container">
<div class="payment-header">
<h2>Thanh toán đơn hàng</h2>
<div class="amount" id="display-amount"></div>
</div>
<div class="payment-body">
<div class="qr-wrapper">
<img id="qr-image" alt="QR Code thanh toán" />
</div>
<p style="color: #6b7280; font-size: 14px;">Mở app ngân hàng &rarr; Quét mã QR &rarr; Xác nhận chuyển khoản</p>
<div class="transfer-info">
<div class="row">
<span class="label">Ngân hàng</span>
<span class="value" id="display-bank"></span>
</div>
<div class="row">
<span class="label">Số tài khoản</span>
<span class="value" id="display-acc"></span>
</div>
<div class="row">
<span class="label">Số tiền</span>
<span class="value" id="display-money"></span>
</div>
<div class="row">
<span class="label">Nội dung CK</span>
<span class="value" id="display-content"></span>
</div>
</div>
<div class="status waiting" id="payment-status">Đang chờ thanh toán...</div>
<div class="countdown" id="countdown"></div>
</div>
</div>
 
<script>
// === CẤU HÌNH ===
const ORDER = {
code: 'DH12345', // Mã đơn hàng (mã thanh toán)
amount: 100000, // Số tiền (VND)
bank: 'Vietcombank', // Ngân hàng thụ hưởng
accountNumber: '0010000000355', // Số tài khoản thụ hưởng
accountName: 'NGUYEN VAN A', // Tên chủ tài khoản
};
 
// === HIỂN THỊ ===
const formatVND = (n) => n.toLocaleString('vi-VN') + ' VND';
document.getElementById('display-amount').textContent = formatVND(ORDER.amount);
document.getElementById('display-bank').textContent = ORDER.bank;
document.getElementById('display-acc').textContent = ORDER.accountNumber;
document.getElementById('display-money').textContent = formatVND(ORDER.amount);
document.getElementById('display-content').textContent = ORDER.code;
 
// QR Code image
const qrUrl = `https://qr.sepay.vn/img?acc=${ORDER.accountNumber}&bank=${encodeURIComponent(ORDER.bank)}&amount=${ORDER.amount}&des=${encodeURIComponent(ORDER.code)}`;
document.getElementById('qr-image').src = qrUrl;
 
// === POLLING KIỂM TRA TRẠNG THÁI ===
let pollInterval;
 
async function checkPaymentStatus() {
try {
const res = await fetch(`/api/orders/${ORDER.code}/status`);
const data = await res.json();
 
if (data.status === 'paid') {
clearInterval(pollInterval);
const statusEl = document.getElementById('payment-status');
statusEl.textContent = '✓ Thanh toán thành công!';
statusEl.className = 'status success';
document.getElementById('countdown').textContent = '';
}
} catch (err) {
console.error('Lỗi kiểm tra trạng thái:', err);
}
}
 
// Kiểm tra mỗi 3 giây
pollInterval = setInterval(checkPaymentStatus, 3000);
 
// Countdown 15 phút
let remaining = 15 * 60;
const countdownEl = document.getElementById('countdown');
const timer = setInterval(() => {
remaining--;
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
countdownEl.textContent = `Đơn hàng hết hạn sau ${mins}:${secs.toString().padStart(2, '0')}`;
if (remaining <= 0) {
clearInterval(timer);
clearInterval(pollInterval);
countdownEl.textContent = 'Đơn hàng đã hết hạn';
}
}, 1000);
</script>
</body>
</html>

Backend: Tạo đơn hàng và nhận Webhook

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
<?php
// === 1. TẠO ĐƠN HÀNG ===
// File: create-order.php
// Tạo đơn hàng và trả về thông tin cho trang thanh toán
 
$orderId = 'DH' . time(); // Mã thanh toán duy nhất
$amount = 100000;
 
// Lưu đơn hàng vào database
$pdo = new PDO('mysql:host=localhost;dbname=myshop', 'user', 'pass');
$stmt = $pdo->prepare('INSERT INTO orders (code, amount, status, created_at) VALUES (?, ?, ?, NOW())');
$stmt->execute([$orderId, $amount, 'pending']);
 
// Trả về thông tin để frontend hiển thị QR
echo json_encode([
'code' => $orderId,
'amount' => $amount,
'bank' => 'Vietcombank',
'accountNumber' => '0010000000355',
'qrUrl' => "https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount={$amount}&des={$orderId}"
]);
 
 
// === 2. NHẬN WEBHOOK TỪ SEPAY ===
// File: webhook/sepay.php
 
$data = json_decode(file_get_contents('php://input'));
if (!is_object($data)) {
http_response_code(400);
echo json_encode(['success' => false]);
exit;
}
 
// Xác thực API Key
$apiKey = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($apiKey !== 'Apikey YOUR_API_KEY') {
http_response_code(401);
echo json_encode(['success' => false]);
exit;
}
 
// Chống trùng lặp
$pdo = new PDO('mysql:host=localhost;dbname=myshop', 'user', 'pass');
$stmt = $pdo->prepare('SELECT id FROM webhook_logs WHERE transaction_id = ?');
$stmt->execute([$data->id]);
if ($stmt->fetch()) {
echo json_encode(['success' => true]); // Đã xử lý, bỏ qua
exit;
}
 
// Lưu log webhook
$stmt = $pdo->prepare('INSERT INTO webhook_logs (transaction_id, body, created_at) VALUES (?, ?, NOW())');
$stmt->execute([$data->id, file_get_contents('php://input')]);
 
// Xử lý giao dịch tiền VÀO có mã thanh toán
if ($data->transferType === 'in' && $data->code) {
$stmt = $pdo->prepare('SELECT * FROM orders WHERE code = ? AND status = ?');
$stmt->execute([$data->code, 'pending']);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
 
if ($order && $data->transferAmount >= $order['amount']) {
// Cập nhật đơn hàng thành Đã thanh toán
$stmt = $pdo->prepare('UPDATE orders SET status = ?, paid_at = NOW() WHERE code = ?');
$stmt->execute(['paid', $data->code]);
 
// TODO: Gửi email xác nhận, cập nhật kho,...
}
}
 
http_response_code(200);
echo json_encode(['success' => true]);
 

Database schema mẫu

SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- Bảng đơn hàng
CREATE TABLE `orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`code` varchar(50) NOT NULL COMMENT 'Mã thanh toán (nội dung CK)',
`amount` int(11) NOT NULL COMMENT 'Số tiền (VND)',
`status` enum('pending','paid','expired','cancelled') NOT NULL DEFAULT 'pending',
`paid_at` datetime DEFAULT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
-- Bảng log webhook (chống trùng lặp)
CREATE TABLE `webhook_logs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`transaction_id` int(11) NOT NULL COMMENT 'ID giao dịch trên SePay',
`body` text NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_transaction_id` (`transaction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Lưu ý quan trọng

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

Mã thanh toán (trường code) phải duy nhất cho mỗi đơn hàng và khó đoán. Tránh dùng số thứ tự tăng dần (1, 2, 3...). Nên kết hợp prefix + timestamp hoặc random string (ví dụ: DH1709123456, ORD-A7X9K2).

Kiểm tra số tiền

Luôn so sánh transferAmount từ webhook với số tiền đơn hàng. Chỉ xác nhận thanh toán khi số tiền chuyển khoản số tiền đơn hàng. Điều này tránh trường hợp khách chuyển thiếu.

Phản hồi nhanh

Webhook có response timeout 8 giây. Nếu xử lý nghiệp vụ phức tạp (gửi email, gọi API bên thứ ba), hãy phản hồi {"success": true} ngay rồi xử lý bất đồng bộ qua queue.


Bước tiếp theo

  1. Tạo và nhúng QR Code — Chi tiết tham số QR và danh sách ngân hàng
  2. Tạo Webhooks — Cấu hình chi tiết webhook (sự kiện, điều kiện, retry, chứng thực)
  3. Đối soát giao dịch — Bổ sung cơ chế đối soát để không bỏ sót giao dịch