Transactions
Generate dynamic QRIS transactions and track them through to payment.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/qris/generate | Generate a dynamic QRIS |
GET | /v1/qris-history | List transactions for the key’s mode (payment_status, from, to, limit, offset) |
GET | /v1/qris-history/:id | Check a transaction’s payment status |
POST | /v1/qris-history/:id/simulate-payment | Sandbox only: mark a transaction paid and fire the signed webhook |
POST | /v1/qris-history/:id/resend-webhook | Manually redeliver the webhook for a paid/expired transaction |
GET | /v1/usage | Today’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.
Generate: request body
Section titled “Generate: request body”POST /v1/qris/generate — JSON body:
| Field | Required | Notes |
|---|---|---|
qris_id | yes | From QRIS Codes |
amount | yes | Integer ≥ 1000. A random offset (1–999) is always added — this is what makes amount-based payment matching possible |
fee | no | { type: "fixed" | "percentage", value: number >= 0 } |
expires_in_minutes | no | Omit 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_expiry | no | Boolean, default false — see below |
idempotency_key | no | Non-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.
Pricing
Section titled “Pricing”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:
| SKU | When it applies | Price |
|---|---|---|
| QR Standard | Default 15-minute expiry, allow_payment_after_expiry unset/false | Rp100 |
| QR Flexible | Custom expires_in_minutes and/or allow_payment_after_expiry: true | Rp1.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.
Add-ons
Section titled “Add-ons”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-on | Default | With the add-on active |
|---|---|---|
| Retensi log | History/webhook logs kept 90 days | Kept 365 days |
| Limit transaksi harian | 100 live generates/day | 500 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.
Checking usage
Section titled “Checking usage”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.
simulate-payment
Section titled “simulate-payment”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.
Example responses
Section titled “Example responses”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-payment — qris_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 →