Ringkasan Utama
QRIS dinamis menanamkan nominal di QR baru untuk setiap transaksi, dibuat lewat API PSP berlisensi Indonesia dan dikonfirmasi lewat webhook bertanda tangan. Mulai 1 Oktober 2026, Bank Indonesia menetapkan MDR 0 persen sampai Rp100.000 untuk merchant reguler (0,7 persen di atasnya) dan sampai Rp500.000 untuk merchant mikro (0,3 persen di atasnya).
Pada sebuah proyek untuk klien di Indonesia, saya harus menambahkan QRIS ke alur checkout, dan hal pertama yang membingungkan tim adalah menganggap QRIS itu satu jenis saja. Padahal bukan. Ada versi statis yang Anda cetak jadi stiker, dan versi dinamis yang Anda buat langsung dari backend. Untuk apa pun yang menyerupai aplikasi atau toko online, Anda butuh QRIS dinamis, dan artikel ini membahas persis cara membangunnya di Node.js.
QRIS (Quick Response Code Indonesian Standard) adalah standar QR terpadu dari Bank Indonesia: satu QR yang bisa dipindai dan dibayar oleh e-wallet atau aplikasi mobile banking mana pun yang terdaftar (GoPay, OVO, DANA, ShopeePay, aplikasi bank). Sebagai developer, Anda hampir tidak pernah berbicara langsung dengan jaringan QRIS. Anda lewat PSP berlisensi, dan pekerjaan nyata Anda adalah membuat QR, menampilkannya, dan bereaksi terhadap webhook saat uang masuk.
QRIS statis vs dinamis: mana yang Anda butuhkan?
Perbedaannya penting untuk UX sekaligus rekonsiliasi. Dengan QR statis, pelanggan memindai satu kode cetak Anda dan mengetik nominalnya sendiri — cukup untuk warung, tapi menyulitkan untuk software karena Anda tidak bisa mengaitkan pembayaran ke pesanan tertentu secara andal. QR dinamis dicetak per transaksi dengan nominal terkunci, dan membawa referensi pesanan yang bisa dicocokkan webhook Anda. Berikut perbandingannya.
| Aspek | QRIS statis | QRIS dinamis |
|---|---|---|
| Nominal | Diketik pelanggan secara manual | Tertanam dan terkunci saat dibuat |
| Masa berlaku QR | Satu QR cetak dipakai selamanya | QR baru per transaksi, kedaluwarsa |
| Rekonsiliasi | Manual, sulit mencocokkan pesanan ke pembayaran | Otomatis via order_id di webhook |
| Paling cocok untuk | Warung, kotak tip, volume rendah | Aplikasi, e-commerce, checkout berpesanan |
| Upaya integrasi | Cetak stiker, tanpa kode | Panggilan API plus handler webhook |
Membuat QRIS dinamis lewat PSP berlisensi
Anda tidak bisa secara legal membuat QRIS sah dari nol. Setiap QR harus memuat NMID asli dan ditandatangani ke jaringan switching nasional Bank Indonesia, yang hanya bisa dilakukan acquirer dan PJSP berlisensi. Jadi arsitekturnya selalu sama: backend Anda memanggil PSP, PSP mengembalikan QR, dan PSP menyelesaikan dana ke rekening Anda dikurangi MDR. Berikut PSP berlisensi yang biasa dipakai developer Indonesia.
- Midtrans (Core API dan Snap) — cakupan e-wallet paling luas, SDK Node.js yang kuat
- Xendit — API rapi, populer di kalangan startup dan fintech
- DOKU — sudah lama berdiri, kuat untuk kebutuhan enterprise dan biller
- API bank langsung (BRI, BCA, Mandiri) — jika Anda sudah menjadi nasabah dan ingin biaya lebih rendah di volume tinggi
NMID dan onboarding merchant
Sebelum satu QR pun berfungsi, Anda harus di-onboarding sebagai merchant. PSP Anda mengumpulkan dokumen usaha (KTP dan NIK untuk perorangan, NPWP dan akta pendirian untuk perusahaan), memverifikasi legalitas, dan acquirer menerbitkan NMID — National Merchant ID yang tertanam di setiap QR yang Anda tampilkan dan menghubungkan tiap pembayaran ke bisnis Anda di repositori merchant nasional. Lama penerbitannya bergantung pada PSP dan acquirer Anda, jadi tanyakan perkiraan waktunya sebelum menetapkan tanggal peluncuran. Tidak ada yang bisa bertransaksi sampai ini selesai, jadi mulai onboarding lebih awal.
Alur MPM: membuat QR dinamis di Node.js
QRIS dinamis memakai MPM — Merchant Presented Mode — artinya merchant menampilkan QR dan pelanggan yang memindai (kebalikannya CPM, di mana pelanggan menunjukkan QR aplikasinya ke alat pindai). Alur MPM dalam kode ada tiga langkah: charge, render, konfirmasi. Dengan Midtrans Core API dan SDK Node.js mereka, membuat QR cukup satu panggilan charge dengan payment_type qris.
// Generate a dynamic QRIS via Midtrans Core API
import midtransClient from "midtrans-client";
const core = new midtransClient.CoreApi({
isProduction: false,
serverKey: process.env.MIDTRANS_SERVER_KEY,
clientKey: process.env.MIDTRANS_CLIENT_KEY,
});
const charge = await core.charge({
payment_type: "qris",
transaction_details: {
order_id: `INV-${Date.now()}`,
gross_amount: 275000, // IDR, integer only — no decimals
},
qris: { acquirer: "gopay" }, // or "airpay_shopee" for ShopeePay
// Set the expiry yourself so the countdown has a known value
custom_expiry: { expiry_duration: 15, unit: "minute" },
});
// The QR to show the customer lives in the actions array (MPM)
const qr = charge.actions.find((a) => a.name === "generate-qr-code");
console.log(charge.transaction_id, qr.url);Respons charge mengembalikan array actions. Ambil URL action generate-qr-code lalu tampilkan gambarnya, atau tanam string QR di frontend Anda sendiri. Simpan juga transaction_id dan masa berlaku yang Anda tetapkan. QR dinamis akan kedaluwarsa dan setiap penyedia punya default sendiri: panduan GoPay QRIS Midtrans menyebut 15 menit, yang bisa diubah dengan custom_expiry, jadi tetapkan masa berlakunya secara eksplisit atau cek default gateway Anda, lalu tampilkan hitung mundur dan tombol regenerate daripada QR yang mati diam-diam.
Menangani callback/webhook pembayaran
Di sinilah kebanyakan integrasi diam-diam rusak. Aplikasi pelanggan bilang lunas, tapi backend Anda baru benar-benar tahu ketika PSP mengirim webhook ke notification URL Anda. Atur URL itu di dashboard PSP, siapkan endpoint HTTPS publik, dan — yang paling penting — verifikasi signature sebelum mempercayai apa pun. Midtrans menandatangani tiap notifikasi sebagai hash SHA-512 dari order_id, status_code, gross_amount, dan server key Anda. Identitas QR yang dibayar datang sebagai merchant_cross_reference_id.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/midtrans", async (req, res) => {
const { order_id, status_code, gross_amount, signature_key,
transaction_status, fraud_status } = req.body;
// gross_amount here is a string like "275000.00"
const expected = crypto
.createHash("sha512")
.update(order_id + status_code + gross_amount + process.env.MIDTRANS_SERVER_KEY)
.digest("hex");
if (expected !== signature_key) {
return res.status(403).json({ error: "invalid signature" });
}
if (transaction_status === "settlement" && fraud_status === "accept") {
// Idempotent: order_id may arrive more than once
// await orders.markPaidOnce(order_id);
}
// Respond 200 fast so the PSP stops retrying
res.status(200).json({ received: true });
});Jangan pernah menandai pesanan lunas dari frontend atau redirect — keduanya bisa dipalsukan penyerang. Percayai hanya webhook yang signature key-nya cocok dengan hash yang Anda hitung ulang, dan buat handler-nya idempoten: PSP melakukan retry, jadi settlement yang sama bisa datang beberapa kali. Balas 200 dengan cepat dan kerjakan proses berat secara asinkron, kalau tidak PSP terus retry dan menggandakan efek samping Anda.
Struktur biaya MDR
Terakhir, soal uang. MDR (Merchant Discount Rate) adalah biaya yang dipotong PSP dari tiap settlement — Anda menerima nominal kotor dikurangi MDR. Bank Indonesia menetapkan tarifnya, dan aturannya berubah lagi pada 1 Oktober 2026, saat MDR 0% diperluas ke merchant kecil, menengah, dan besar untuk transaksi sampai Rp 100.000. Tabel BI juga menetapkan tarif khusus 0,6% untuk pendidikan dan 0,4% untuk SPBU, yang tidak disebut terpisah dalam siaran pers BI Agustus 2026. Verifikasi angka terkini ke Bank Indonesia sebelum mengutipnya ke klien, karena ini didorong kebijakan dan bisa berubah. Sejak 1 Oktober 2026:
| Kategori merchant | MDR (sejak 1 Okt 2026) |
|---|---|
| Mikro (UMI), transaksi sampai Rp 500.000 | 0% (gratis) |
| Mikro (UMI), transaksi di atas Rp 500.000 | 0,3% |
| Kecil, menengah, besar (UKE, UME, UBE) | 0% sampai Rp 100.000; 0,7% di atasnya |
| Layanan publik, G2P, P2G, donasi | 0% |
Dua catatan praktis: MDR secara hukum ditanggung merchant dan tidak boleh dibebankan sebagai surcharge ke pelanggan, jadi masukkan ke margin Anda, bukan total checkout. Dan bangun rekonsiliasi dengan asumsi settlement bersih — catat gross_amount dari webhook, kurangi tier MDR, dan cocokkan dengan yang benar-benar masuk ke rekening agar selisih 0,7 persen tidak terlihat seperti pembayaran hilang.
Sumber
- Bank Indonesia — Merchant Discount Rate (MDR) QRIS
- Midtrans Docs — QRIS Core API charge reference
- Midtrans Docs — HTTP(S) Notification / Webhooks
- Bank Indonesia (BICARA 131) — Identifying a QRIS issuer via the NSS code / NMID
- Midtrans Docs — Integrasi GoPay QRIS POS (masa berlaku default dan custom_expiry)

