Skip to content

Transactions

Generate dynamic QRIS transactions and track them through to payment.

MethodPathPurpose
POST/v1/qris/generateGenerate a dynamic QRIS
GET/v1/qris-historyList transactions for the key’s mode (payment_status, from, to, limit, offset)
GET/v1/qris-history/:idCheck a transaction’s payment status
POST/v1/qris-history/:id/simulate-paymentSandbox only: mark a transaction paid and fire the signed webhook
POST/v1/qris-history/:id/resend-webhookManually redeliver the webhook for a paid/expired transaction
GET/v1/usageToday’s generate count and daily cap for the key’s mode

A custom from/to range on GET /v1/qris-history can’t exceed 90 days without an active “Log Retention — 365 Days” add-on (400 VALIDATION_ERROR otherwise) — rows past the standard 90-day retention window won’t exist for a tenant without it anyway, so this just fails clearly instead of returning a confusingly-empty tail. The platform’s own internal tenant is exempt.

POST /v1/qris/generate — JSON body:

FieldRequiredNotes
qris_idyesFrom QRIS Codes
amountyesInteger ≥ 1000. A random offset (1–999) is always added — this is what makes amount-based payment matching possible
feeno{ type: "fixed" | "percentage", value: number >= 0 }
expires_in_minutesnoOmit for the 15-minute default; a number from 1 up to 10080 (7 days) for a custom window (e.g. 60 for 1 hour). No never-expire option — see below
allow_payment_after_expirynoBoolean, default false — see below
idempotency_keynoNon-empty string, up to 255 characters — see below

allow_payment_after_expiry controls whether a transaction can still be settled once it’s expired: with the default false, both the inbound webhook matcher and simulate-payment refuse an expired transaction (inbound ignores it, simulate-payment returns 409 PAYMENT_EXPIRED); with true, a late payment is accepted and the resulting record has paid_after_expiry: true. This is a per-transaction opt-in set at generate time, not a global setting.

expires_in_minutes has no unbounded/never-expire option: the 90-day retention sweep (see Known limitations) deletes qg_qris_history rows by age regardless of payment_status, so a transaction living longer than that would silently fall out of payment matching. 7 days keeps a wide safety margin under that window.

idempotency_key makes a retry of the same logical generate call safe: a second request with the same idempotency_key for the same API key’s mode returns the original transaction instead of creating a new one (no new row, no duplicate side effects). Omit it and every call creates a new transaction as before.

Each successful generate call is billed from the tenant’s wallet balance — pay-as-you-go, no subscription. Two SKUs, decided by which fields the request actually uses:

SKUWhen it appliesPrice
QR StandardDefault 15-minute expiry, allow_payment_after_expiry unset/falseRp100
QR FlexibleCustom expires_in_minutes and/or allow_payment_after_expiry: trueRp1.000

A charge that would take the balance below zero fails the whole call with 402 INSUFFICIENT_BALANCE — no QRIS is generated, top up and retry. Sandbox mode is never billed. amount/fee (the payer-facing transaction value) are unrelated to this — pricing is per API call, not a percentage of the QRIS transaction amount.

Optional monthly subscriptions, managed as wallet-core subscriptions against the tenant’s wallet_account_id — not part of this API surface directly, but they change two behaviors here:

Add-onDefaultWith the add-on active
Retensi logHistory/webhook logs kept 90 daysKept 365 days
Limit transaksi harian100 live generates/day500 or 1.000/day, depending on the tier subscribed

Both add-on benefits apply only to live mode: sandbox has no daily cap at all (unlimited by design, the free eval channel) and is unaffected by the retention add-on either way — its history still gets swept at the standard 90-day cutoff regardless of a live-mode tenant’s add-on status, since the extended-retention benefit only ever applies to live rows. A tenant with no linked wallet (no wallet_account_id) still gets the default 100/day cap on live — only the platform’s own internal tenant (is_platform_tenant) is exempt from the daily cap entirely.

Hitting the daily cap fails the call with 429 DAILY_LIMIT_EXCEEDED before any charge is made — retry after the day rolls over, or subscribe to a higher tier. A subscription mid-retry after one missed renewal (grace status on wallet-core’s side) still keeps the benefit; it’s only lost once the subscription is actually suspended.

GET /v1/usage — today’s generate count and the resolved cap for the key’s mode, so you can check before hitting it rather than only finding out via 429:

{ "success": true, "data": { "generated_today": 42, "daily_limit": 100 } }

daily_limit is null in sandbox mode or for the platform’s own internal tenant — both are uncapped, so there’s no ceiling to report.

Sandbox-only (a live-mode key gets 400 VALIDATION_ERROR). Marks the transaction paid, fires the payment.success webhook, and lets you exercise the full paid flow without a real payment — see Flow Overview.

POST /v1/qris/generate:

{
"success": true,
"message": "QRIS successfully generated",
"data": {
"history_id": "1285922c-ddd2-4c3e-b8ee-533c520d08cd",
"qris_string": "00020101021226430017ID.CO.EXAMPLE.WWW...5405250835802ID5911TOKO CONTOH6007JAKARTA63043D02",
"original_amount": "25000",
"final_amount": "25083",
"payment_status": "unpaid",
"expires_at": "2026-07-08T09:49:35.942Z"
}
}

GET /v1/qris-history/:id (same shape for simulate-paymentqris_string carries the amount only while unpaid; see Known limitations). payment_source is which wallet app’s notification matched (e.g. "GoPay"), resolved from the App Listener’s inbound payload — null for anything not confirmed that way (simulate-payment, still unpaid/expired):

{
"success": true,
"data": {
"history_id": "1285922c-ddd2-4c3e-b8ee-533c520d08cd",
"qris_string": "00020101021126430017ID.CO.EXAMPLE.WWW...5802ID5911TOKO CONTOH6007JAKARTA63041AB2",
"original_amount": "25000",
"final_amount": "25083",
"payment_status": "paid",
"payment_source": "GoPay",
"paid_after_expiry": false,
"expires_at": "2026-07-08T09:49:35.942Z",
"paid_at": "2026-07-08T09:34:35.953Z",
"created_at": "2026-07-08T09:34:35.942Z",
"updated_at": "2026-07-08T09:34:35.953Z"
}
}

GET /v1/qris-history — same shape as GET /v1/qris-history/:id, one per transaction, except no payment_source (only the single-transaction lookup resolves it):

{
"success": true,
"data": [ /* same shape as GET /v1/qris-history/:id, minus payment_source */ ],
"pagination": { "total": 1, "limit": 5, "offset": 0 }
}

POST /v1/qris-history/:id/resend-webhook:

{
"success": true,
"message": "Webhook resent",
"data": { "event_type": "payment.success", "attempt_number": 2, "response_status": 200, "success": true, "attempted_at": "2026-07-08T09:34:36.475Z" }
}

Continue to Webhooks