Skip to content

Flow Overview

Upload the merchant’s real static QRIS (image or raw EMV string) via POST /v1/qris-codes — once per mode, using that mode’s key. You get back a qris_id; reuse it for every transaction in that same mode. Live and sandbox never share a registration, even for the same physical QRIS — generating in sandbox needs a qris_id uploaded with the sandbox key, live needs one uploaded with the live key. This is deliberate: it’s what lets sandbox-generated QR codes be made non-payable (see Known limitations) without touching live at all.

POST /v1/qris/generate with qris_id and amount. The response includes qris_string (render this as the QR code shown to the payer) and history_id (use this to track the transaction). A random offset (1–999) is added to the amount so payments can be matched uniquely; the transaction defaults to expiring in 15 minutes. qris_id must belong to the same mode as the API key used, or you get QRIS_NOT_FOUND.

The payer pays the amount shown. In live mode, confirmation comes from the App Listener reading the merchant’s payment app notification and calling the inbound webhook. In sandbox, call POST /v1/qris-history/:id/simulate-payment instead — no real payment needed.

The moment a transaction is marked paid, a signed webhook (payment.success) is sent to your configured webhook_url — see Webhooks. You can also poll GET /v1/qris-history/:id at any time; it’s the source of truth if a webhook delivery is delayed or lost.

┌────────┐
│ unpaid │
└───┬────┘
┌──────────────┴──────────────┐
│ payment confirmed │ expires_at elapses
▼ ▼
┌──────┐ ┌─────────┐
│ paid │◀───────────────────│ expired │
└──────┘ late payment └─────────┘
(only if allow_payment_after_expiry was
set at generate time — otherwise expired
is terminal)

expired is set within 30 seconds by a background sweep, or immediately if the transaction happens to be read (GET) first. See Known limitations for what expired does — and does not — prevent.

Continue to QRIS Codes