Key Takeaway
A dynamic QRIS embeds the amount in a fresh QR per transaction, generated through a licensed Indonesian PSP's API and confirmed by a signed webhook. From 1 October 2026, Bank Indonesia sets MDR at 0 percent up to Rp100,000 for regular merchants (0.7 percent above) and up to Rp500,000 for micro merchants (0.3 percent above).
On a recent project I had to add QRIS to an Indonesian checkout, and the first thing that tripped up the team was assuming QRIS is one thing. It is not. There is a static flavor you print on a sticker and a dynamic flavor you generate on the fly from your backend. For anything resembling an app or an online store, you want dynamic QRIS, and this post walks through exactly how to build that in Node.js.
QRIS (Quick Response Code Indonesian Standard) is Bank Indonesia's unified QR standard: one QR that any registered e-wallet or mobile banking app (GoPay, OVO, DANA, ShopeePay, bank apps) can scan and pay. As a developer you almost never talk to the QRIS network directly. You go through a licensed PSP, and your real work is generating the QR, rendering it, and reacting to a webhook when the money lands.
Static vs dynamic QRIS: which one do you need?
The difference matters for both UX and reconciliation. With a static QR the customer scans your one printed code and types the amount themselves — fine for a warung, painful for software because you cannot reliably tie a payment back to a specific order. A dynamic QR is minted per transaction with the amount locked in, and it carries an order reference your webhook can match. Here is how they compare.
| Aspect | Static QRIS | Dynamic QRIS |
|---|---|---|
| Amount | Customer types it in manually | Embedded and locked at generation |
| QR lifetime | One printed QR reused forever | Fresh QR per transaction, expires |
| Reconciliation | Manual, hard to match order to payment | Automatic via order_id in the webhook |
| Best for | Warungs, tip jars, low volume | Apps, e-commerce, checkout with an order |
| Integration effort | Print a sticker, no code | API call plus a webhook handler |
Generating dynamic QRIS through a licensed PSP
You cannot legally mint a valid QRIS from scratch. Every QR must embed a real NMID and be signed into Bank Indonesia's national switching network, which only licensed acquirers and PJSPs can do. So the architecture is always the same: your backend calls a PSP, the PSP returns a QR, and the PSP settles funds to your account minus MDR. These are the licensed PSPs Indonesian developers usually reach for.
- Midtrans (Core API and Snap) — widest e-wallet coverage, strong Node.js SDK
- Xendit — clean API, popular with startups and fintech
- DOKU — long-established, strong on enterprise and biller use cases
- Direct bank APIs (BRI, BCA, Mandiri) — if you already bank with them and want lower fees at higher volume
NMID and merchant onboarding
Before a single QR works you have to be onboarded as a merchant. Your PSP collects your business documents (KTP and NIK for individuals, NPWP and deed of establishment for companies), verifies legality, and your acquirer issues an NMID — the National Merchant ID that is baked into every QR you present and links each payment to your business in the national merchant repository. How long issuance takes depends on your PSP and acquirer, so ask for their current turnaround before you commit to a launch date. Nothing you build will transact until this finishes, so start onboarding early.
The MPM flow: generating a dynamic QR in Node.js
Dynamic QRIS uses MPM — Merchant Presented Mode — meaning the merchant presents the QR and the customer scans it (the opposite is CPM, where the customer shows their app's QR to a scanner). The MPM flow in code is three steps: charge, render, confirm. With Midtrans Core API and their Node.js SDK, generating the QR is a single charge call with payment_type set to 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);The charge response returns an actions array. Grab the generate-qr-code action URL and render that image, or embed the QR string in your own frontend. Also store transaction_id and the expiry you set. A dynamic QR expires and each provider sets its own default: Midtrans's GoPay QRIS guide gives 15 minutes, adjustable with custom_expiry, so set the expiry explicitly or check your gateway's default, then show a countdown and a regenerate button rather than a silently dead QR.
Handling the payment callback/webhook
This is where most integrations quietly break. The customer's app says paid, but your backend only truly knows when the PSP posts a webhook to your notification URL. Configure that URL in the PSP dashboard, expose a public HTTPS endpoint, and — critically — verify the signature before trusting anything. Midtrans signs each notification as an SHA-512 hash of order_id, status_code, gross_amount, and your server key. The identifier of the QR being paid arrives as 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 });
});Never mark an order paid from the frontend or a redirect — attackers can forge both. Trust only a webhook whose signature key matches your recomputed hash, and make the handler idempotent: PSPs retry, so the same settlement can arrive several times. Respond 200 fast and do heavy work asynchronously, or the PSP keeps retrying and duplicates your side effects.
The MDR fee structure
Finally, the money. MDR (Merchant Discount Rate) is the fee your PSP deducts from each settlement — you receive the gross amount minus MDR. Bank Indonesia sets the rates, and they changed again on 1 October 2026, when the 0% band was extended to small, medium and large merchants for transactions up to Rp 100,000. BI's table also sets special rates of 0.6% for education and 0.4% for public fuel stations (SPBU), which BI's August 2026 announcement does not mention separately. Verify the current numbers against Bank Indonesia before quoting them to a client, because these are policy-driven and shift. As of 1 October 2026:
| Merchant category | MDR (from 1 Oct 2026) |
|---|---|
| Micro (UMI), transaction up to Rp 500,000 | 0% (free) |
| Micro (UMI), transaction above Rp 500,000 | 0.3% |
| Small, medium, large (UKE, UME, UBE) | 0% up to Rp 100,000; 0.7% above |
| Public service, G2P, P2G, donations | 0% |
Two practical notes: MDR is legally borne by the merchant and cannot be surcharged to the customer, so price it into your margins, not the checkout total. And build reconciliation expecting net settlement — log gross_amount from the webhook, subtract the MDR tier, and reconcile against what actually hits your bank so a 0.7 percent gap never looks like a missing payment.
Sources

