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.
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.
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.
POST /payments from your backend.payment_url.GET /payments/{payment_id} or wait for the webhook.payment.paid arrives.Send your API key as a bearer token on every request.
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.
Request body
Response 201
| Field | Type | Required | Notes |
|---|---|---|---|
| amount | integer | yes | IDR, no decimals |
| order_id | string | yes | Unique per merchant |
| description | string | no | Shown on the checkout page |
| customer.name | string | no | Stored with the payment |
| customer.email | string | no | Stored with the payment |
| qris_id | integer | no | Defaults to your default QRIS |
Listing is available with GET /payments?page=1&status=PAID.
| Status | Meaning |
|---|---|
| PENDING | QR issued, waiting for verified settlement |
| PAID | Provider confirmed payment; fee settled from your wallet |
| EXPIRED | Not paid before expires_at; reservation released |
| CANCELLED | Cancelled by the merchant; reservation released |
| FAILED | Provider reported a failure |
| REFUNDED / PARTIALLY_REFUNDED | A 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.
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:
| Path | Typical delay | How 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
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:
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_PROVIDER | Auto paid? | Use for |
|---|---|---|
null | No | Default. No settlement feed exists, so a super admin reconciles each payment by hand |
sandbox | Yes, simulated | Local testing only. Requires APP_DEBUG=true and settles after SANDBOX_AUTO_PAID_AFTER_SECONDS |
midtrans | Yes, real | Template driver for a licensed provider: signed callbacks plus polling |
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
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.
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.
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.
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_PROVIDER | Auto credited? | Behaviour |
|---|---|---|
manual | No | Default. A super admin approves each invoice after checking the incoming funds |
qris | No | Shows the platform QRIS for the exact amount, still approved by a super admin |
sandbox | Yes, simulated | Testing 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.
TOPUP ledger entry. Scanning the QR
does not credit anything by itself, and no balance changes without a ledger row behind it.
404. It is documented so the contract stays stable when it ships - do not build
against it yet.
Planned contract
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.
qris_live_xxxxxxxxxxxx (test keys use qris_test_).| HTTP | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Malformed JSON body |
| 401 | INVALID_API_KEY | Missing or unknown bearer token |
| 403 | ACCOUNT_SUSPENDED / API_DISABLED | Account cannot use the API |
| 402 | INSUFFICIENT_BALANCE | Wallet cannot cover amount + fee |
| 404 | PAYMENT_NOT_FOUND | Unknown payment id |
| 409 | DUPLICATE_ORDER_ID | order_id already used |
| 422 | VALIDATION_ERROR | Field validation failed |
| 429 | RATE_LIMITED | Too many requests |
| 500 | SERVER_ERROR | Unexpected error |
Register endpoints under Dashboard › Webhooks. Events:
Delivery
Signature verification (PHP)
Retries: 1m, 5m, 15m, 30m, 1h (5 attempts). Respond 2xx to acknowledge.
These are the opposite direction: your payment provider notifies QRISIFY, which is what makes a
payment turn PAID instantly. Point the provider dashboard at:
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.
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/.
Device endpoints do not use an API key. Every request is signed with the per-device secret issued when you register the device.
The timestamp must be within 300 seconds of server time. Bodies over 8192 bytes are rejected
with 413.
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.
Same signature scheme, empty body. Used by the app to test connectivity and to refresh
last_seen_at.
payment.paid webhook behave exactly as with any other driver.| Code | HTTP | Meaning |
|---|---|---|
DEVICE_UNAUTHORIZED | 401 | Unknown device, revoked device, or bad signature |
DEVICE_TIMESTAMP_SKEW | 401 | Timestamp outside the 300 second window |
NOTIFICATION_REJECTED | 422 | Package not allowed, no amount found, or ambiguous match |
PAYLOAD_TOO_LARGE | 413 | Body over 8192 bytes |
INVALID_REQUEST | 400 | Malformed 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.
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.
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.