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.

Before you start

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

Code
1
https://qr.sepay.vn/img?acc={ACCOUNT_NUMBER}&bank={BANK_NAME}&amount={AMOUNT}&des={DESCRIPTION}
accstringrequired
Beneficiary bank account number
bankstringrequired
Bank short name. See the list at qr.sepay.vn/banks.json
amountinteger
Transfer amount (VND)
desstring
Transfer description (URL-encoded)

Example

Code
1
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=DH12345
Payment QR Code
Click to expand
Sample payment QR Code
Dynamic QR Codes

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

  1. Customer places an order → system creates an order with a unique payment code (e.g., DH12345)
  2. Payment page displays a QR Code containing the payment code in the transfer description
  3. Customer scans QR → makes the transfer
  4. SePay detects the transaction → sends webhook to your server with code = "DH12345"
  5. Server updates the order to Paid
  6. Payment page auto-updates to show Payment Successful (via polling or WebSocket)

Frontend: Payment Page

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="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 &rarr; Scan QR &rarr; 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 bank
accountNumber: '0010000000355', // Beneficiary account
accountName: '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 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 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 seconds
pollInterval = setInterval(checkPaymentStatus, 3000);
 
// 15-minute countdown
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 = `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

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
<?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 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. 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 processed
exit;
}
 
// 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 code
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']) {
// 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

SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- Orders table
CREATE 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

Secure Payment Codes

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).

Verify Transfer Amount

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.

Respond Quickly

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

  1. Generate and Embed QR Code — Full QR parameters and bank list
  2. Create Webhooks — Detailed webhook configuration (events, conditions, retry, authentication)
  3. Transaction Reconciliation — Add reconciliation to catch any missed transactions