QR Code and Payment Form
Guide to creating bank transfer QR codes and building a payment form with QR + Webhooks for automatic payment confirmation.
This guide shows you how to combine bank transfer QR Codes with SePay Webhooks to build an automated payment flow: customer scans QR → transfers money → system automatically confirms the order.
Make sure you have:
- Linked a bank account on my.sepay.vn
- Created a webhook to receive transaction notifications — see Quick Start
- Configured payment code at Company → General Settings → Payment Code Structure
Creating Bank Transfer QR Codes
SePay provides a QR Code image generator at qr.sepay.vn. When customers scan the code with their banking app, all transfer details (bank, account number, amount, description) are auto-filled.
URL Structure
https://qr.sepay.vn/img?acc={ACCOUNT_NUMBER}&bank={BANK_NAME}&amount={AMOUNT}&des={DESCRIPTION}
Example
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=DH12345

You can generate dynamic QR codes by changing amount and des for each order. Each order gets a unique QR with the order code in the transfer description so SePay can auto-detect it.
Full QR Code documentation: Generate and Embed QR Code
Building a Payment Form with QR
Below is a guide to create a simple payment form: display order info, QR image, and automatically update status when the customer pays.
How It Works
- Customer places an order → system creates an order with a unique payment code (e.g.,
DH12345) - Payment page displays a QR Code containing the payment code in the transfer description
- Customer scans QR → makes the transfer
- SePay detects the transaction → sends webhook to your server with
code = "DH12345" - Server updates the order to Paid
- Payment page auto-updates to show Payment Successful (via polling or WebSocket)
Frontend: Payment Page
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Order Payment</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>Order Payment</h2><div class="amount" id="display-amount"></div></div><div class="payment-body"><div class="qr-wrapper"><img id="qr-image" alt="Payment QR Code" /></div><p style="color: #6b7280; font-size: 14px;">Open banking app → Scan QR → Confirm transfer</p><div class="transfer-info"><div class="row"><span class="label">Bank</span><span class="value" id="display-bank"></span></div><div class="row"><span class="label">Account Number</span><span class="value" id="display-acc"></span></div><div class="row"><span class="label">Amount</span><span class="value" id="display-money"></span></div><div class="row"><span class="label">Description</span><span class="value" id="display-content"></span></div></div><div class="status waiting" id="payment-status">Waiting for payment...</div><div class="countdown" id="countdown"></div></div></div><script>// === CONFIGURATION ===const ORDER = {code: 'DH12345', // Payment code (order ID)amount: 100000, // Amount (VND)bank: 'Vietcombank', // Beneficiary bankaccountNumber: '0010000000355', // Beneficiary accountaccountName: 'NGUYEN VAN A', // Account holder name};// === DISPLAY ===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 FOR PAYMENT STATUS ===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 = '✓ Payment successful!';statusEl.className = 'status success';document.getElementById('countdown').textContent = '';}} catch (err) {console.error('Status check error:', err);}}// Check every 3 secondspollInterval = setInterval(checkPaymentStatus, 3000);// 15-minute countdownlet remaining = 15 * 60;const countdownEl = document.getElementById('countdown');const timer = setInterval(() => {remaining--;const mins = Math.floor(remaining / 60);const secs = remaining % 60;countdownEl.textContent = `Order expires in ${mins}:${secs.toString().padStart(2, '0')}`;if (remaining <= 0) {clearInterval(timer);clearInterval(pollInterval);countdownEl.textContent = 'Order has expired';}}, 1000);</script></body></html>
Backend: Create Order and Receive Webhook
<?php// === 1. CREATE ORDER ===// File: create-order.php$orderId = 'DH' . time(); // Unique payment code$amount = 100000;// Save order to 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']);// Return info for frontend to display 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. RECEIVE WEBHOOK FROM 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;}// Verify API Key$apiKey = $_SERVER['HTTP_AUTHORIZATION'] ?? '';if ($apiKey !== 'Apikey YOUR_API_KEY') {http_response_code(401);echo json_encode(['success' => false]);exit;}// Deduplication$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]); // Already processedexit;}// 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')]);// Process incoming transaction with payment codeif ($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']) {// Update order to Paid$stmt = $pdo->prepare('UPDATE orders SET status = ?, paid_at = NOW() WHERE code = ?');$stmt->execute(['paid', $data->code]);// TODO: Send confirmation email, update inventory, etc.}}http_response_code(200);echo json_encode(['success' => true]);
Sample Database Schema
-- Orders tableCREATE TABLE `orders` (`id` int(11) NOT NULL AUTO_INCREMENT,`code` varchar(50) NOT NULL COMMENT 'Payment code (transfer description)',`amount` int(11) NOT NULL COMMENT 'Amount (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;-- Webhook logs table (deduplication)CREATE TABLE `webhook_logs` (`id` int(11) NOT NULL AUTO_INCREMENT,`transaction_id` int(11) NOT NULL COMMENT 'Transaction ID on 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;
Important Notes
Payment codes (the code field) must be unique per order and hard to guess. Avoid sequential numbers (1, 2, 3...). Use prefix + timestamp or random strings (e.g., DH1709123456, ORD-A7X9K2).
Always compare transferAmount from the webhook with the order amount. Only confirm payment when the transferred amount is ≥ the order amount. This prevents cases where the customer transfers less than required.
Webhooks have an 8-second response timeout. If your processing is complex (sending emails, calling third-party APIs), respond with {"success": true} immediately and handle business logic asynchronously via a queue.
Next Steps
- Generate and Embed QR Code — Full QR parameters and bank list
- Create Webhooks — Detailed webhook configuration (events, conditions, retry, authentication)
- Transaction Reconciliation — Add reconciliation to catch any missed transactions