QRISIFY API

QRISIFY turns a merchant static QRIS into a per-transaction dynamic QRIS and hosts a checkout page for it. The API is REST over HTTPS, speaks JSON, and is authenticated with a bearer API key.

Base URL: https://msqris.mkz.biz.id/api/v1

Always call the API over https. The base URL above follows the scheme you are reading this page with, so if it shows http, correct APP_URL in the server environment before going live: a mismatch there blocks the dashboard stylesheet as mixed content and downgrades form submissions from POST to GET.

A payment stays PENDING until a configured payment provider confirms settlement. Generating a QR never marks a payment as paid. Once a provider is configured, the switch to PAID happens by itself - no human step is involved. See Settlement & Auto Paid.
Platform fee is 0.5% per transaction, charged to your QRISIFY wallet balance.

Quick Start

  1. Register a QRISIFY account.
  2. Top up your wallet balance.
  3. Add your static QRIS under Dashboard › QRIS.
  4. Create an API key and store it in your server environment.
  5. Call POST /payments from your backend.
  6. Redirect the customer to the returned payment_url.
  7. Poll GET /payments/{payment_id} or wait for the webhook.
  8. Mark your order as paid when payment.paid arrives.

Authentication

Send your API key as a bearer token on every request.

Authorization: Bearer qris_live_xxxxxxxxxxxxxxxx Content-Type: application/json

Keys are stored as hashes. The plaintext value is shown only once at creation. Keys can be revoked or rotated at any time and expose a last_used_at timestamp. Never ship a key in browser or mobile client code.

Create Payment

POST https://msqris.mkz.biz.id/api/v1/payments

Request body

{ "amount": 100000, "order_id": "ORDER-12345", "description": "Pembelian Produk", "customer": { "name": "Ahmad", "email": "customer@example.com" } }

Response 201

{ "success": true, "payment": { "id": "PAY-20260812-000001", "order_id": "ORDER-12345", "amount": 100000, "fee": 500, "total": 100500, "status": "PENDING", "payment_url": "https://msqris.mkz.biz.id/pay/PAY-20260812-000001", "expires_at": "2026-08-12 20:15:00" } }
FieldTypeRequiredNotes
amountintegeryesIDR, no decimals
order_idstringyesUnique per merchant
descriptionstringnoShown on the checkout page
customer.namestringnoStored with the payment
customer.emailstringnoStored with the payment
qris_idintegernoDefaults to your default QRIS

Get Payment

GET https://msqris.mkz.biz.id/api/v1/payments/{payment_id}
{ "success": true, "payment": { "id": "PAY-20260812-000001", "order_id": "ORDER-12345", "amount": 100000, "fee": 500, "net_amount": 99500, "status": "PENDING" } }

Listing is available with GET /payments?page=1&status=PAID.

Payment Status

StatusMeaning
PENDINGQR issued, waiting for verified settlement
PAIDProvider confirmed payment; fee settled from your wallet
EXPIREDNot paid before expires_at; reservation released
CANCELLEDCancelled by the merchant; reservation released
FAILEDProvider reported a failure
REFUNDED / PARTIALLY_REFUNDEDA refund ledger entry was created

Every transition is written to the payment timeline and mirrored to your webhook endpoints. You never have to ask a QRISIFY operator to move a payment forward.

Settlement & Auto Paid

A payment turns PAID automatically as soon as a settlement source confirms it. QRISIFY listens on three paths at once, so whichever notices first wins:

PathTypical delayHow it fires
Provider webhook instant Your provider posts a signed callback to POST https://msqris.mkz.biz.id/api/v1/webhooks/provider/payment
Status read a few seconds Opening the checkout page or calling GET /payments/{id} also refreshes the status from the provider, throttled per payment
Background sweep up to 1 minute bin/worker.php polls every pending payment, even when nobody has the page open

Whichever path fires, the same thing happens

reserved balance released -> net amount settled and fee charged in the wallet ledger -> payment.paid event appended to the timeline -> payment.paid webhook queued for your endpoints

The whole chain is idempotent and row locked, so a webhook and a poll arriving at the same moment still produce exactly one ledger entry.

Required: run the worker

The sweep and the webhook queue both live in the worker. Add one cron entry:

* * * * * /usr/bin/php /path/to/qrisify/bin/worker.php >> /path/to/qrisify/storage/logs/worker.log 2>&1

The worker verifies before it expires anything, so a payment completed in the final seconds is settled instead of being written off as EXPIRED.

Provider drivers

PAYMENT_PROVIDERAuto paid?Use for
nullNoDefault. No settlement feed exists, so a super admin reconciles each payment by hand
sandboxYes, simulatedLocal testing only. Requires APP_DEBUG=true and settles after SANDBOX_AUTO_PAID_AFTER_SECONDS
midtransYes, realTemplate driver for a licensed provider: signed callbacks plus polling
Automatic settlement is only as real as the driver behind it. With PAYMENT_PROVIDER=null the money moves straight into the merchant account and QRISIFY has nothing to read, so no code can detect the payment and manual reconciliation stays the only honest option. QRISIFY never reads phone notifications, bank mutations or e-wallet accounts.

Trying it end to end

APP_DEBUG=true PAYMENT_PROVIDER=sandbox SANDBOX_AUTO_PAID_AFTER_SECONDS=10

Create a payment, wait ten seconds, then reload the checkout page: the badge flips to PAID on its own and the payment.paid webhook is queued.

Verify Payment

POST https://msqris.mkz.biz.id/api/v1/payments/{payment_id}/verify

Forces an immediate provider check instead of waiting for the next sweep. Useful right after a customer says they paid, or from your own reconciliation job.

{ "success": true, "verified": true, "payment": { "id": "PAY-20260812-000001", "order_id": "ORDER-12345", "status": "PAID", "amount": 100000, "fee": 500, "paid_at": "2026-08-13 09:41:02" } }

Safe to call repeatedly: settlement is guarded by an idempotency key, so the wallet is never credited or charged twice. "verified": false simply means the provider has not seen the money yet - keep the order pending and try again later.

Wallet Top Up

Fees are charged against your QRISIFY wallet, so the wallet has to hold balance before a payment can be created. Top up from Dashboard › Wallet › Top Up; an invoice is issued with a dynamic QRIS for the exact amount.

TOPUP_PROVIDERAuto credited?Behaviour
manualNoDefault. A super admin approves each invoice after checking the incoming funds
qrisNoShows the platform QRIS for the exact amount, still approved by a super admin
sandboxYes, simulatedTesting only. Requires APP_DEBUG=true

When the driver has a settlement feed, top ups follow exactly the same three paths as payments: signed callback to POST https://msqris.mkz.biz.id/api/v1/webhooks/provider/topup, a refresh whenever the invoice screen is open, and the one minute worker sweep. The invoice page updates itself, so a credited top up appears without reloading.

A wallet is only ever credited through a verified TOPUP ledger entry. Scanning the QR does not credit anything by itself, and no balance changes without a ledger row behind it.

Refunds Planned

Not available yet in this release. The refund engine exists and is ledger safe, but it is not reachable from the API or the dashboard, so the request below would return 404. It is documented so the contract stays stable when it ships - do not build against it yet.

Planned contract

POST https://msqris.mkz.biz.id/api/v1/refunds
{ "payment_id": "PAY-20260812-000001", "amount": 25000, "type": "PARTIAL", "reason": "Item out of stock" }

type is one of FULL, PARTIAL or REVERSAL. Only a PAID or PARTIALLY_REFUNDED payment can be refunded, and the total refunded can never exceed the settled amount.

Refunds never mutate the original ledger rows. Each refund appends a new REFUND or REVERSAL entry, so history stays auditable. A REVERSAL also returns the platform fee.

API Keys

  • Format: qris_live_xxxxxxxxxxxx (test keys use qris_test_).
  • Shown once. QRISIFY stores only a hash plus the last four characters.
  • Revoke or regenerate from Dashboard › API Keys.
  • Rate limit: 100 requests / minute / key.

Errors

{ "success": false, "error": { "code": "INSUFFICIENT_BALANCE", "message": "Insufficient QRISIFY balance" } }
HTTPCodeMeaning
400BAD_REQUESTMalformed JSON body
401INVALID_API_KEYMissing or unknown bearer token
403ACCOUNT_SUSPENDED / API_DISABLEDAccount cannot use the API
402INSUFFICIENT_BALANCEWallet cannot cover amount + fee
404PAYMENT_NOT_FOUNDUnknown payment id
409DUPLICATE_ORDER_IDorder_id already used
422VALIDATION_ERRORField validation failed
429RATE_LIMITEDToo many requests
500SERVER_ERRORUnexpected error

Webhooks

Register endpoints under Dashboard › Webhooks. Events:

payment.pending payment.paid payment.expired payment.cancelled payment.refunded

Delivery

POST https://merchant.com/webhook/qrisify X-QRISIFY-EVENT: payment.paid X-QRISIFY-TIMESTAMP: 1786000000 X-QRISIFY-SIGNATURE: 9f2c... { "event": "payment.paid", "payment_id": "PAY-20260812-000001", "order_id": "ORDER-12345", "amount": 100000, "status": "PAID" }

Signature verification (PHP)

$raw = file_get_contents('php://input'); $ts = $_SERVER['HTTP_X_QRISIFY_TIMESTAMP'] ?? ''; $sig = $_SERVER['HTTP_X_QRISIFY_SIGNATURE'] ?? ''; $expected = hash_hmac('sha256', $ts . '.' . $raw, $webhookSecret); if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }

Retries: 1m, 5m, 15m, 30m, 1h (5 attempts). Respond 2xx to acknowledge.

Inbound provider callbacks

These are the opposite direction: your payment provider notifies QRISIFY, which is what makes a payment turn PAID instantly. Point the provider dashboard at:

POST https://msqris.mkz.biz.id/api/v1/webhooks/provider/payment POST https://msqris.mkz.biz.id/api/v1/webhooks/provider/topup

Both endpoints are unauthenticated by design and protected by signature instead. Every callback is verified by the active driver before anything is written; an unsigned, wrongly signed or unparsable body is rejected with 401 and nothing changes. Rejections are logged to storage/logs/webhook.log.

The shared secret lives in PAYMENT_PROVIDER_WEBHOOK_SECRET (and TOPUP_PROVIDER_WEBHOOK_SECRET). Leaving it empty disables the callback path entirely, which is the safe default.

Notification Devices

An optional settlement path for merchants who receive a payment alert on an Android phone. The companion app forwards those alerts to QRISIFY, which matches them against pending payments and top ups. Devices are managed under Dashboard › Devices, and the Android project ships in android/.

A phone notification is not a settlement record. Any app able to post a notification can forge one, reversals and chargebacks are invisible to it, and delivery is best effort. Treat this as an accelerator on top of bank reconciliation, never as the books.

Authentication

Device endpoints do not use an API key. Every request is signed with the per-device secret issued when you register the device.

X-QRISIFY-DEVICE: dev_xxxxxxxxxxxxxxxx X-QRISIFY-TIMESTAMP: 1765600000 X-QRISIFY-SIGNATURE: hex(hmac_sha256(secret, timestamp + "." + raw_body))

The timestamp must be within 300 seconds of server time. Bodies over 8192 bytes are rejected with 413.

POST /device/notifications

{ "fingerprint": "64 hex characters identifying this alert", "package": "id.dana", "title": "Payment received", "text": "You received Rp50.000", "amount_hint": 50000, "posted_at": 1765600000 }

amount_hint is only a hint. The server parses the amount from the text itself and that parse is authoritative. fingerprint makes the call idempotent, so a retry can never settle the same alert twice.

{ "success": true, "matched": true, "reference": "PAY-20260812-000001", "message": "Payment settled" }

POST /device/ping

Same signature scheme, empty body. Used by the app to test connectivity and to refresh last_seen_at.

Matching rules

  • The sending package must be on the server-side allowlist. The server list always wins over the app.
  • The parsed amount must match a pending payment or top up exactly.
  • If two pending records share the same amount, QRISIFY settles neither and logs the ambiguity. Use unique amounts.
  • If two unclaimed alerts match one record, it also refuses.
  • An alert can only settle a record that already exists. It can never create money.
  • Settlement runs through the normal provider path, so the ledger, the fee and the payment.paid webhook behave exactly as with any other driver.

Errors

CodeHTTPMeaning
DEVICE_UNAUTHORIZED401Unknown device, revoked device, or bad signature
DEVICE_TIMESTAMP_SKEW401Timestamp outside the 300 second window
NOTIFICATION_REJECTED422Package not allowed, no amount found, or ambiguous match
PAYLOAD_TOO_LARGE413Body over 8192 bytes
INVALID_REQUEST400Malformed JSON or malformed fingerprint

Enable with PAYMENT_PROVIDER=notification and TOPUP_PROVIDER=notification. Unlike the sandbox driver these are allowed in production, so the responsibility for the caveats above is yours.

PHP Native

<?php $ch = curl_init('https://msqris.mkz.biz.id/api/v1/payments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('QRISIFY_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 100000, 'order_id' => 'ORDER-12345', ]), ]); $response = json_decode(curl_exec($ch), true); header('Location: ' . $response['payment']['payment_url']);

JavaScript (Fetch)

const res = await fetch('https://msqris.mkz.biz.id/api/v1/payments', { method: 'POST', headers: { 'Authorization': 'Bearer ' + process.env.QRISIFY_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: 100000, order_id: 'ORDER-12345' }) }); const data = await res.json(); if (!data.success) throw new Error(data.error.code); console.log(data.payment.payment_url);

cURL

curl -X POST https://msqris.mkz.biz.id/api/v1/payments \ -H "Authorization: Bearer qris_live_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount":100000,"order_id":"ORDER-12345"}'
curl https://msqris.mkz.biz.id/api/v1/payments/PAY-20260812-000001 \ -H "Authorization: Bearer qris_live_xxxxxxxx"

Python

import os, requests res = requests.post( "https://msqris.mkz.biz.id/api/v1/payments", headers={"Authorization": "Bearer " + os.environ["QRISIFY_KEY"]}, json={"amount": 100000, "order_id": "ORDER-12345"}, timeout=15, ) data = res.json() if not data["success"]: raise RuntimeError(data["error"]["code"]) print(data["payment"]["payment_url"])

Full integration flow

Customer app -> Merchant backend -> POST /api/v1/payments -> QRISIFY validates key, balance, fee, reservation -> Dynamic QRIS + payment_url returned -> Customer scans and pays -> Provider confirms, by whichever comes first: signed callback / status read / worker sweep -> QRISIFY sets PAID and settles the fee in the ledger -> Webhook payment.paid -> Merchant marks the order PAID

Recommended integration: trust the payment.paid webhook as the primary signal and poll GET /payments/{id} every 5-10 seconds on your checkout screen as a fallback.

Try It

Requests run from your browser against your own key. Nothing is stored by this page and no secret key is embedded in the documentation. Use a test key where possible.

Response will appear here.