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.
Đả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
https://qr.sepay.vn/img?acc={SO_TAI_KHOAN}&bank={NGAN_HANG}&amount={SO_TIEN}&des={NOI_DUNG}
Ví dụ
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=DH12345

Bạn có thể tạo QR động bằng cách thay đổi amount và des 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
- 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) - Trang thanh toán hiển thị QR Code chứa mã thanh toán trong nội dung chuyển khoản
- Khách hàng quét QR → chuyển khoản
- SePay nhận giao dịch → gửi webhook đến server của bạn với
code = "DH12345" - Server cập nhật đơn hàng thành Đã thanh toán
- 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
<!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 → Quét mã QR → 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ưởngaccountNumber: '0010000000355', // Số tài khoản thụ hưởngaccountName: '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 imageconst 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âypollInterval = setInterval(checkPaymentStatus, 3000);// Countdown 15 phútlet 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
<?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ị QRecho 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ỏ quaexit;}// 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ánif ($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
-- Bảng đơn hàngCREATE 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
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).
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.
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
- Tạo và nhúng QR Code — Chi tiết tham số QR và danh sách ngân hàng
- Tạo Webhooks — Cấu hình chi tiết webhook (sự kiện, điều kiện, retry, chứng thực)
- Đối soát giao dịch — Bổ sung cơ chế đối soát để không bỏ sót giao dịch