QR Payments
Accept instant payments via QR codes through national real-time payment systems including PromptPay (Thailand), PayNow (Singapore), and DuitNow QR (Malaysia).
Overview
Payment method features, limits, user statistics, and fees are subject to change. Information is based on publicly available sources and may not reflect your specific merchant agreement. Always refer to official Omise documentation and your merchant dashboard for current, binding information.
QR payments allow customers to scan a QR code with their banking or wallet app to make instant payments. These are national payment schemes backed by central banks, offering real-time settlement and wide bank support.
Why QR Payments?
- ⚡ Instant - Real-time payment confirmation
- 🏦 Bank-backed - National payment infrastructure
- 📱 Convenient - Scan with any banking app
- 💰 Low cost - Often lower fees than cards
- 🔐 Secure - Bank-grade security
- 🌐 Universal - Works with all banks in the country
Supported QR Payment Methods
Thailand 🇹🇭
| Method | Users | Type | Refundable | Settlement |
|---|---|---|---|---|
| PromptPay | 60M+ | National QR | ✅ Yes | Instant |
| TrueMoney QR | 30M+ | Wallet QR | ✅ Yes | 1-3 days |
Singapore 🇸🇬
| Method | Users | Type | Refundable | Settlement |
|---|---|---|---|---|
| PayNow | 5M+ | National QR | ✅ Yes | Instant |
Malaysia 🇲🇾
| Method | Users | Type | Refundable | Settlement |
|---|---|---|---|---|
| DuitNow QR | 30M+ | National QR | ✅ Yes | Instant |
| Maybank QR | 10M+ | Bank-specific | ✅ Yes | 1-3 days |
How QR Payments Work
Customer Experience:
- Merchant displays QR code
- Customer opens their banking/wallet app
- Scans QR code with camera
- Reviews payment details
- Confirms with PIN/biometric (5 seconds)
- Receives instant confirmation
Typical completion time: 10-30 seconds
Implementation Overview
Basic Integration
- Node.js
- PHP
- Python
const omise = require('omise')({
secretKey: 'skey_test_YOUR_SECRET_KEY'
});
// Create QR payment source
const source = await omise.sources.create({
type: 'promptpay', // or paynow, duitnow_qr, etc.
amount: 50000,
currency: 'THB'
});
// Get QR code image
console.log('QR Code:', source.scannable_code.image.download_uri);
// Create charge
const charge = await omise.charges.create({
amount: 50000,
currency: 'THB',
source: source.id
});
// Poll for payment status or use webhooks
<?php
// Create source
$source = OmiseSource::create(array(
'type' => 'promptpay',
'amount' => 50000,
'currency' => 'THB'
));
// Get QR code
$qr_code_url = $source['scannable_code']['image']['download_uri'];
// Create charge
$charge = OmiseCharge::create(array(
'amount' => 50000,
'currency' => 'THB',
'source' => $source['id']
));
?>
import omise
omise.api_secret = 'skey_test_YOUR_SECRET_KEY'
# Create source
source = omise.Source.create(
type='promptpay',
amount=50000,
currency='THB'
)
# Get QR code
qr_code_url = source.scannable_code['image']['download_uri']
# Create charge
charge = omise.Charge.create(
amount=50000,
currency='THB',
source=source.id
)
Display QR Code and Poll for Status
<div class="qr-payment">
<h3>สแกนเพื่อชำระเงิน (Scan to Pay)</h3>
<img id="qr-code" src="{{ qr_code_url }}" alt="QR Code">
<div id="status">
<p>รอการชำระเงิน...</p>
<div class="spinner"></div>
</div>
<p class="instructions">
เปิดแอพธนาคารและสแกน QR Code ด้านบน
</p>
</div>
<script>
// Poll for payment status
const chargeId = '{{ charge_id }}';
const pollInterval = setInterval(async () => {
const response = await fetch(`/api/charges/${chargeId}/status`);
const data = await response.json();
if (data.status === 'successful') {
clearInterval(pollInterval);
window.location = '/payment-success';
} else if (data.status === 'failed') {
clearInterval(pollInterval);
document.getElementById('status').innerHTML =
'<p class="error">การชำระเงินล้มเหลว กรุณาลองใหม่</p>';
}
}, 3000); // Check every 3 seconds
// Timeout after 5 minutes
setTimeout(() => {
clearInterval(pollInterval);
document.getElementById('status').innerHTML =
'<p class="error">QR Code หมดอายุ กรุณาลองใหม่</p>';
}, 300000);
</script>
Comparison Matrix
| Feature | PromptPay | PayNow | DuitNow QR | Maybank QR |
|---|---|---|---|---|
| Country | Thailand | Singapore | Malaysia | Malaysia |
| Users | 60M+ | 5M+ | 30M+ | 10M+ |
| Banks | All Thai banks | All SG banks | All MY banks | Maybank only |
| Speed | Instant | Instant | Instant | 1-3 days |
| Refunds | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| Currency | THB | SGD | MYR | MYR |
QR Payments vs Other Methods
| Feature | QR Payments | Mobile Banking | Digital Wallets |
|---|---|---|---|
| Speed | 10-30 sec | 30-90 sec | 30-90 sec |
| Platform | Any device | Mobile only | Mobile only |
| Bank Support | All banks | Major banks | Specific wallets |
| Desktop | ✅ Yes | ❌ No | ❌ No |
| App Required | Banking app | Banking app | Wallet app |
| Setup | None | None | Wallet account |
Use Cases
Perfect For:
In-Store Payments
- Point of sale systems
- Restaurant bills
- Retail checkout
E-commerce (Desktop)
- Desktop shoppers without mobile banking
- Customers who prefer QR over cards
- Cross-device payments
Bills and Invoices
- Utility payments
- Invoice settlements
- B2B payments
Not Ideal For:
- Mobile-only experiences (use mobile banking)
- International customers (country-specific)
- Very small amounts (minimum limits apply)
Implementation Patterns
Responsive QR Display
.qr-payment {
text-align: center;
padding: 30px;
}
#qr-code {
width: 100%;
max-width: 300px;
height: auto;
margin: 20px auto;
display: block;
border: 10px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
@media (max-width: 768px) {
#qr-code {
max-width: 250px;
}
}
Status Polling
async function pollPaymentStatus(chargeId) {
const maxAttempts = 100; // 5 minutes (100 * 3 seconds)
let attempts = 0;
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
attempts++;
try {
const response = await fetch(`/api/charges/${chargeId}`);
const charge = await response.json();
if (charge.status === 'successful') {
clearInterval(interval);
resolve(charge);
} else if (charge.status === 'failed') {
clearInterval(interval);
reject(new Error('Payment failed'));
} else if (attempts >= maxAttempts) {
clearInterval(interval);
reject(new Error('Payment timeout'));
}
} catch (error) {
clearInterval(interval);
reject(error);
}
}, 3000);
});
}
Multi-Language Instructions
const instructions = {
th: {
title: 'สแกนเพื่อชำระเงิน',
step1: 'เปิดแอพธนาคาร',
step2: 'สแกน QR Code',
step3: 'ยืนยันการชำระเงิน'
},
en: {
title: 'Scan to Pay',
step1: 'Open your banking app',
step2: 'Scan the QR code',
step3: 'Confirm the payment'
}
};
Best Practices
1. Show Clear Instructions
<div class="qr-instructions">
<h4>วิธีชำระเงิน (How to Pay):</h4>
<ol>
<li>เปิดแอพธนาคารหรือ Mobile Banking</li>
<li>เลือก "สแกน QR" หรือ "Scan"</li>
<li>สแกน QR Code ด้านบน</li>
<li>ตรวจสอบจำนวนเงินและยืนยัน</li>
</ol>
<div class="bank-support">
<p><small>รองรับทุกธนาคารใน{{ country }}</small></p>
</div>
</div>
2. Handle QR Expiration
const QR_EXPIRY = 5 * 60 * 1000; // 5 minutes
setTimeout(() => {
if (!paymentCompleted) {
showMessage('QR Code หมดอายุ คลิกเพื่อสร้างใหม่');
enableRetry();
}
}, QR_EXPIRY);
3. Use Webhooks
app.post('/webhooks/omise', (req, res) => {
const event = req.body;
if (event.key === 'charge.complete') {
const charge = event.data;
if (['promptpay', 'paynow', 'duitnow_qr'].includes(charge.source.type)) {
if (charge.status === 'successful') {
processOrder(charge.metadata.order_id);
}
}
}
res.sendStatus(200);
});
4. Optimize QR Size
// Larger QR for desktop, smaller for mobile
const qrSize = isMobile() ? 250 : 300;
img.style.width = `${qrSize}px`;
img.style.maxWidth = '100%';
5. Show Payment Apps
<div class="compatible-apps">
<p>ใช้ได้กับแอพ:</p>
<div class="app-icons">
<img src="/apps/banking-apps.svg" alt="Banking Apps">
<span>+ แอพธนาคารทุกธนาคาร</span>
</div>
</div>
Common Issues
Issue: QR Code not scanning
Solution:
- Ensure proper contrast and size
- Check QR code image quality
- Display against white background
- Minimum size: 200x200px
Issue: Payment timeout
Solution:
if (Date.now() - chargeCreatedAt > 5 * 60 * 1000) {
showMessage('QR Code expired. Generate new code?');
enableRetryButton();
}
Issue: Customer confusion
Solution: Show supported banks and apps clearly:
<div class="supported-banks">
<h4>รองรับทุกธนาคาร:</h4>
<div class="bank-logos">
<!-- Show bank logos -->
</div>
</div>
FAQ
What are QR payments?
QR payments use national payment schemes (PromptPay, PayNow, DuitNow) where customers scan a QR code with their banking app to make instant payments. Works with all banks in the country.
Which QR method should I use?
Use the national QR scheme for your target country:
- Thailand: PromptPay
- Singapore: PayNow
- Malaysia: DuitNow QR (all banks) or Maybank QR (Maybank only)
Do customers need a special app?
No, customers can use their regular banking app. All banks in each country support their national QR payment scheme.
How long are QR codes valid?
Typically 5 minutes. After expiration, generate a new QR code for the customer.
Can I refund QR payments?
Yes, all QR payment methods support both full and partial refunds.
Do QR payments work on mobile?
Yes, but mobile banking apps provide better UX on mobile devices. QR payments are ideal for:
- Desktop users
- In-store payments
- Cross-device payments (display QR on one device, scan with another)
Related Resources
- Payment Methods Overview - All available methods
- PromptPay - Thailand national QR
- PayNow - Singapore national QR
- DuitNow QR - Malaysia national QR
- Mobile Banking - Alternative for mobile users
- Testing - Test QR payments
Next Steps
- Choose your target country's QR method
- Implement source creation
- Display QR code on your site
- Set up status polling or webhooks
- Handle QR expiration
- Test with actual banking apps
- Go live
Ready to start? Choose your QR payment method: