Integrasi Pembayaran QRIS Dinamis di Node.js: Panduan Lengkap

Foto oleh Mallikarjunasj via Wikimedia Commons (CC0)
QRIS statis adalah satu QR cetak yang dipindai semua pelanggan lalu nominalnya diketik manual. QRIS dinamis dibuat per transaksi dengan nominal yang sudah tertanam, jadi pelanggan tinggal konfirmasi dan bayar. QRIS dinamis juga memungkinkan backend Anda merekonsiliasi setiap pembayaran otomatis berdasarkan order ID.
Praktis tidak bisa. QR QRIS harus memuat NMID sah yang diterbitkan acquirer atau PJSP berlisensi Bank Indonesia, dan QR-nya harus ditandatangani ke jaringan switching nasional. Anda membuat QRIS dinamis dengan memanggil API PSP berlisensi seperti Midtrans, Xendit, atau DOKU, yang mengurus NMID dan settlement untuk Anda.
Sejak 15 Maret 2025, Bank Indonesia menetapkan MDR 0 persen untuk merchant mikro pada transaksi sampai Rp 500.000 dan 0,3 persen di atasnya. Merchant kecil, menengah, dan besar reguler dikenakan 0,7 persen, sedangkan kategori layanan publik, pemerintah, dan donasi 0 persen. Biaya ini ditanggung merchant dan tidak boleh dibebankan ke pelanggan.
Anda memastikannya lewat webhook (HTTP notification) yang dikirim PSP ke server Anda saat status berubah menjadi settlement. Selalu verifikasi signature key sebelum mempercayainya, dan jangan mengandalkan layar pelanggan atau frontend saja. Jadikan webhook sebagai satu-satunya sumber kebenaran dan buat handler-nya idempoten.
NMID (National Merchant ID) adalah identitas merchant unik yang diterbitkan acquirer dan tertanam di setiap QRIS yang Anda tampilkan. NMID menghubungkan tiap transaksi ke bisnis Anda yang terdaftar di repositori merchant nasional Bank Indonesia. Saat integrasi lewat PSP, NMID disediakan pada proses onboarding merchant yang biasanya butuh beberapa hari kerja.

Foto oleh Mallikarjunasj via Wikimedia Commons (CC0)
Ringkasan Utama
QRIS dinamis menampilkan QR baru untuk setiap transaksi dengan nominal yang sudah tertanam, jadi pelanggan tidak perlu mengetik jumlahnya. Anda membuatnya lewat PSP berlisensi Indonesia melalui API-nya, menampilkan QR yang dikembalikan, lalu memastikan pembayaran via webhook bertanda tangan. Bank Indonesia menetapkan MDR 0,3 persen untuk merchant mikro dan 0,7 persen untuk merchant reguler.
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.
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 |
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.
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. Siapkan waktu beberapa hari kerja: penerbitan NMID biasanya tiga sampai empat hari kerja dan aktivasi QRIS satu sampai dua hari lagi setelah dokumen lolos. Tidak ada yang bisa bertransaksi sampai ini selesai, jadi mulai onboarding lebih awal.
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
});
// 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, charge.expiry_time);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 expiry_time — QR dinamis akan kedaluwarsa (sering sekitar 15 menit), jadi tampilkan hitung mundur dan tombol regenerate daripada QR yang mati diam-diam.
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.
Terakhir, soal uang. MDR (Merchant Discount Rate) adalah biaya yang dipotong PSP dari tiap settlement — Anda menerima nominal kotor dikurangi MDR. Bank Indonesia mengatur batas atasnya, dan aturannya berubah pada 15 Maret 2025. Verifikasi angka terkini ke Bank Indonesia sebelum mengutipnya ke klien, karena ini didorong kebijakan dan bisa berubah. Sesuai jadwal 2025:
| Kategori merchant | MDR (sejak 15 Mar 2025) |
|---|---|
| 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,7% |
| 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.