Skip to main content

DuitNow QR

Accept instant payments via DuitNow QR, Malaysia's national QR payment standard supported by all major banks and e-wallets with 30+ million users.

Overviewโ€‹

DuitNow QR is Malaysia's national QR payment system operated by PayNet, similar to Thailand's PromptPay and Singapore's PayNow. It provides a standardized QR code that works across all participating banks and e-wallets in Malaysia.

Key Features:

  • โœ… Universal acceptance - Works with 30+ banks and e-wallets
  • โœ… Fast confirmation - Near real-time payment verification (typically within seconds)
  • โœ… 30+ million users - Wide adoption across Malaysia
  • โœ… Interoperable - One QR for all banks/wallets
  • โœ… 24/7 availability - Works anytime
  • โœ… Refund support - Full and partial refunds

Supported Regionโ€‹

RegionCurrencyMin AmountMax AmountTransaction Limit
MalaysiaMYRRM 0.01RM 20,000Varies by bank/wallet*

*Daily limits vary by customer's bank/e-wallet and are not specified by Omise

Supported Banks & E-Walletsโ€‹

Major Banks:

  • Maybank
  • CIMB Bank
  • Public Bank
  • RHB Bank
  • Hong Leong Bank
  • AmBank
  • Bank Islam
  • And 20+ more banks

E-Wallets:

  • Boost
  • Touch 'n Go eWallet
  • GrabPay
  • ShopeePay
  • BigPay

How It Worksโ€‹

Customer scans DuitNow QR โ†’ Opens banking/wallet app โ†’ Confirms payment โ†’ Instant confirmation (10-30 seconds total)

Implementationโ€‹

Create Source and Display QRโ€‹

const omise = require('omise')({
secretKey: 'skey_test_YOUR_SECRET_KEY'
});

// Create DuitNow QR source
const source = await omise.sources.create({
type: 'duitnow_qr',
amount: 10000, // RM 100.00
currency: 'MYR'
});

// Get QR code image URL
const qrCodeUrl = source.scannable_code.image.download_uri;

// Create charge
const charge = await omise.charges.create({
amount: 10000,
currency: 'MYR',
source: source.id,
metadata: {
order_id: 'ORD-12345'
}
});

// Display QR code and poll for status
res.render('payment-qr', {
qr_code: qrCodeUrl,
charge_id: charge.id,
amount: 'RM 100.00'
});

Complete Implementationโ€‹

const express = require('express');
const omise = require('omise')({
secretKey: process.env.OMISE_SECRET_KEY
});

const app = express();
app.use(express.json());

// Create DuitNow QR payment
app.post('/checkout/duitnow-qr', async (req, res) => {
try {
const { amount, order_id } = req.body;

// Validate amount
if (amount < 1) {
return res.status(400).json({
error: 'Minimum amount is RM 0.01'
});
}

if (amount > 500000) {
return res.status(400).json({
error: 'Maximum amount is RM 5,000'
});
}

// Create source
const source = await omise.sources.create({
type: 'duitnow_qr',
amount: amount,
currency: 'MYR'
});

// Create charge
const charge = await omise.charges.create({
amount: amount,
currency: 'MYR',
source: source.id,
metadata: { order_id }
});

res.json({
qr_code_url: source.scannable_code.image.download_uri,
charge_id: charge.id,
expires_at: new Date(Date.now() + 5 * 60 * 1000)
});

} catch (error) {
res.status(500).json({ error: error.message });
}
});

// Check charge status
app.get('/api/charges/:chargeId/status', async (req, res) => {
try {
const charge = await omise.charges.retrieve(req.params.chargeId);
res.json({
status: charge.status,
paid: charge.paid
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});

// Webhook
app.post('/webhooks/omise', (req, res) => {
const event = req.body;

if (event.key === 'charge.complete' && event.data.source.type === 'duitnow_qr') {
const charge = event.data;

if (charge.status === 'successful') {
fulfillOrder(charge.metadata.order_id);
}
}

res.sendStatus(200);
});

app.listen(3000);

QR Code Displayโ€‹

<div class="duitnow-payment">
<h3>Scan untuk Bayar (Scan to Pay)</h3>

<div class="qr-container">
<img id="qr-code" src="{{ qr_code_url }}" alt="DuitNow QR Code">
</div>

<div class="amount">
<strong>RM {{ amount }}</strong>
</div>

<div id="status">
<p>Menunggu pembayaran...</p>
<div class="spinner"></div>
</div>

<div class="instructions">
<h4>Cara Bayar:</h4>
<ol>
<li>Buka app bank atau e-wallet anda</li>
<li>Pilih "Scan QR" atau "DuitNow"</li>
<li>Imbas QR code di atas</li>
<li>Sahkan pembayaran</li>
</ol>
</div>

<div class="supported-apps">
<p><small>Boleh guna dengan semua bank dan e-wallet Malaysia</small></p>
</div>
</div>

<style>
.qr-container {
background: white;
padding: 20px;
border-radius: 12px;
display: inline-block;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

#qr-code {
width: 280px;
height: 280px;
display: block;
}

@media (max-width: 768px) {
#qr-code {
width: 240px;
height: 240px;
}
}
</style>

<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);
document.getElementById('status').innerHTML =
'<p class="success">โœ“ Pembayaran Berjaya!</p>';
setTimeout(() => {
window.location = '/payment-success';
}, 1000);
} else if (data.status === 'failed') {
clearInterval(pollInterval);
document.getElementById('status').innerHTML =
'<p class="error">Pembayaran gagal. Sila cuba lagi.</p>';
}
}, 3000);

// Timeout after 5 minutes
setTimeout(() => {
clearInterval(pollInterval);
document.getElementById('status').innerHTML =
'<p class="error">QR code tamat tempoh. Sila jana semula.</p>';
}, 300000);
</script>

Refund Supportโ€‹

// Full or partial refund within 30 days
const refund = await omise.charges.refund('chrg_test_...', {
amount: 10000 // Full or partial
});

Testingโ€‹

Test Amount: RM 100.00 (10000 smallest unit) Expected: Successful payment in test mode

Best Practicesโ€‹

1. Show Supported Banks/Walletsโ€‹

<div class="supported-apps">
<h4>Boleh guna dengan:</h4>
<div class="app-grid">
<div class="app">
<img src="/banks/maybank.svg" alt="Maybank">
<span>Maybank</span>
</div>
<div class="app">
<img src="/banks/cimb.svg" alt="CIMB">
<span>CIMB</span>
</div>
<div class="app">
<img src="/wallets/tng.svg" alt="TnG">
<span>Touch 'n Go</span>
</div>
<div class="app">
<img src="/wallets/boost.svg" alt="Boost">
<span>Boost</span>
</div>
<p><small>+ 30+ bank dan e-wallet lain</small></p>
</div>
</div>

2. Multi-Language Supportโ€‹

const translations = {
ms: {
title: 'Scan untuk Bayar',
waiting: 'Menunggu pembayaran...',
success: 'Pembayaran Berjaya!',
expired: 'QR code tamat tempoh'
},
en: {
title: 'Scan to Pay',
waiting: 'Waiting for payment...',
success: 'Payment Successful!',
expired: 'QR code expired'
}
};

3. Handle QR Expirationโ€‹

const QR_EXPIRY = 5 * 60 * 1000;

setTimeout(() => {
if (!paymentCompleted) {
showExpiredMessage();
enableRetryButton();
}
}, QR_EXPIRY);

FAQโ€‹

What is DuitNow QR?

DuitNow QR is Malaysia's national QR payment standard operated by PayNet. It provides interoperability between all participating banks and e-wallets in Malaysia.

Which banks and e-wallets support DuitNow QR?

All major Malaysian banks (30+) and popular e-wallets including Maybank, CIMB, Public Bank, RHB, Touch 'n Go, Boost, GrabPay, and ShopeePay.

What are the transaction limits?
  • Minimum: RM 0.01
  • Maximum: RM 5,000 per transaction
  • Daily limits vary by bank/e-wallet (typically RM 30,000)
How long are QR codes valid?

DuitNow QR codes are typically valid for 5 minutes. After expiration, generate a new QR code.

Can I refund DuitNow QR payments?

Yes, full and partial refunds are supported within 30 days of the original transaction.

Is it better than FPX?

DuitNow QR:

  • Instant payment
  • Works with QR scanner
  • Better for in-store and mobile

FPX:

  • Internet banking
  • Better for desktop users
  • No refunds

Choose based on your use case.

Next Stepsโ€‹

  1. Create DuitNow QR source
  2. Display QR code on your site
  3. Implement status polling
  4. Set up webhook handler
  5. Handle QR expiration
  6. Test with actual banking apps
  7. Go live