Dynamic QRIS Payment Integration in Node.js: The Complete Guide

Photo by Mallikarjunasj via Wikimedia Commons (CC0)
A static QRIS is a single printed QR that every customer scans and then types the amount into manually. A dynamic QRIS is generated per transaction with the amount already embedded, so the customer just confirms and pays. Dynamic QRIS also lets your backend auto-reconcile each payment against an order ID.
In practice, no. QRIS codes must carry a valid NMID issued by a Bank Indonesia licensed acquirer or PJSP, and the QR must be signed into the national switching network. You generate dynamic QRIS by calling a licensed PSP's API such as Midtrans, Xendit, or DOKU, which handles the NMID and settlement for you.
Since 15 March 2025, Bank Indonesia sets MDR at 0 percent for micro merchants on transactions up to Rp 500,000 and 0.3 percent above that. Regular small, medium, and large merchants pay 0.7 percent, while public service, government, and donation categories are 0 percent. The fee is borne by the merchant and cannot be passed to the customer.
You confirm payment through the webhook (HTTP notification) your PSP sends to your server when the status changes to settlement. Always verify the signature key before trusting it, and never rely on the customer's screen or the frontend alone. Treat the webhook as the single source of truth and make its handler idempotent.
NMID (National Merchant ID) is a unique merchant identifier issued by your acquirer and embedded in every QRIS code you present. It ties each transaction back to your registered business in Bank Indonesia's national merchant repository. When you integrate through a PSP, the NMID is provisioned during merchant onboarding, which usually takes a few working days.

Photo by Mallikarjunasj via Wikimedia Commons (CC0)
Key Takeaway
A dynamic QRIS shows a fresh QR per transaction with the amount already embedded, so the customer never keys it in. You generate it through a licensed Indonesian PSP over its API, render the returned QR, and confirm payment via a signed webhook. Bank Indonesia caps MDR at 0.3 percent for micro merchants, 0.7 percent for regular ones.
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.
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 |
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.
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. Budget a few working days: NMID issuance is typically three to four working days and QRIS activation another one to two after documents clear. Nothing you build will transact until this finishes, so start onboarding early.
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
});
// 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);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 expiry_time — a dynamic QR expires (often around 15 minutes), so show a countdown and a regenerate button rather than a silently dead QR.
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.
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 regulates the ceilings, and they changed on 15 March 2025. Verify the current numbers against Bank Indonesia before quoting them to a client, because these are policy-driven and shift. As of the 2025 schedule:
| Merchant category | MDR (from 15 Mar 2025) |
|---|---|
| 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.7% |
| 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.