API reference
Accept QRIS and USDT payments with one REST API. A transaction is created as pending and becomes paid only after the payment is confirmed server to server. Sandbox payments are never real: you choose their outcome yourself.
Get an API key
Create a sandbox key (pw_test_…) under API Keys and send it as a Bearer token.
Create a payment
POST /api/v1/transactions with your order ID and amount, then show the QR code or open checkout_url.
Confirm with the webhook
Fulfil the order when the signed payment.paid webhook reaches your server.
Every request, except the provider webhook endpoint, needs an API key. Create one in your dashboard under API Keys.
Authorization: Bearer pw_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
429 RATE_LIMITED. Every response, success or error, has an X-Request-Id header, which also appears on the matching row in your API logs.403 IP_NOT_ALLOWED, and you are notified.Every API key belongs to exactly one environment, visible directly in its prefix:
pw_test_…
Transactions created with a sandbox key are tagged sandbox and never appear in your live reports or default dashboard view. To finish a sandbox payment, open its checkout_url and choose Paid, Failed or Expired: your webhook receives the same event a live payment sends.
pw_live_…
Transactions made with a live key are real and count in your dashboard statistics, reports and settlements.
Every response has one of these two formats.
{
"success": true,
"data": {
"…": "…"
}
}
{
"success": false,
"error": {
"code": "INVALID_API_KEY",
"message": "Invalid API credentials."
}
}
| HTTP | Code | Meaning |
|---|---|---|
| 401 | INVALID_API_KEY | Missing, malformed, or unrecognized API key |
| 401 | API_KEY_REVOKED | The key was revoked from the dashboard |
| 403 | IP_NOT_ALLOWED | The API key has an allowed-IP list and the request came from another IP |
| 403 | MERCHANT_INACTIVE | The merchant account is suspended |
| 404 | NOT_FOUND | Resource doesn't exist, or belongs to another merchant |
| 405 | METHOD_NOT_ALLOWED | The endpoint does not accept this HTTP method |
| 409 | DUPLICATE_REFERENCE | Same merchant_reference reused with a different amount/method |
| 422 | VALIDATION_ERROR | Request body failed validation |
| 422 | RETURN_URL_NOT_ALLOWED | return_url is not https, or not on one of your active websites |
| 422 | WEBSITE_NOT_FOUND | website is not one of your active websites |
| 429 | RATE_LIMITED | Too many requests |
| 502 | PROVIDER_ERROR | Live QRIS or USDT: the provider refused or returned an invalid response; the transaction is marked failed |
| 503 | PROVIDER_NOT_CONNECTED | Live QRIS or USDT: the rail's provider is not connected, so no payment can be created |
| 504 | PROVIDER_TIMEOUT | Live QRIS or USDT: the provider did not respond in time; the transaction is marked failed |
Payments
| Field | Notes |
|---|---|
| merchant_reference string · required | Your own order ID. Doubles as the idempotency key. |
| amount number · required | QRIS: whole rupiah, 1,000–10,000,000 IDR. USDT: at least 1, up to 2 decimals |
| payment_method string · required | QRIS (IDR) or USDT (BEP20) only |
| customer_id string · required | Required (max 100). Your own id for the customer: user id, username or email. Shown in your dashboard, exports, API responses and webhooks, so you can see who paid. Also passed to the payment provider as the customer label, unless it contains "@" (emails are never sent). |
| currency string · optional | Optional. Must match the method: IDR for QRIS, USDT for USDT. Stored as sent, without conversion. |
| network string · optional | USDT only, required: BEP20. TRC20/ERC20 are rejected. Omit for QRIS. |
| customer_name string · optional | Optional (max 50), any method. The payer's name, shown in your dashboard. Passed to the provider instead of customer_id when customer_id is an email. |
| return_url string · optional | Optional. Overrides the website's return URL for this payment. https, on one of your active websites (Dashboard → Websites & webhook). |
| website string · optional | Optional. The domain of one of your websites (e.g. shop.example.com); its return URL is used. Omit both to use your default website. A mirror domain counts as its website: the return URL and a webhook URL on the website's domain move to that mirror. |
| expires_in integer · optional | Optional, QRIS only. How long the QR code stays payable, in seconds: 60 to 1,200 (the default). |
{
"merchant_reference": "ORDER-1042",
"amount": 250000,
"currency": "IDR",
"payment_method": "QRIS",
"customer_id": "user-1042",
"customer_name": "Budi Santoso"
}
{
"merchant_reference": "ORDER-1043",
"amount": 100,
"currency": "USDT",
"payment_method": "USDT",
"network": "BEP20",
"customer_id": "user-1043"
}
{
"success": true,
"data": {
"reference": "PW20260921143022AB3F",
"merchant_reference": "ORDER-1042",
"payment_method": "QRIS",
"environment": "sandbox",
"customer_id": "user-1042",
"customer_name": "Budi Santoso",
"amount": 250000,
"fee_bearer": "merchant",
"currency": "IDR",
"network": null,
"status": "pending",
"created_at": "2026-09-21T14:30:22+00:00",
"paid_at": null,
"qr_string": "SANDBOX-QRIS-PW20260921143022AB3F-NOT-PAYABLE",
"pay_address": null,
"pay_amount": 250000,
"expires_at": "2026-09-21T14:50:22+00:00",
"checkout_url": "https://checkout.paywise.asia/Xk7p2QaZ9mRt"
}
}
USDT: show your customer pay_address (a BEP20 address) and pay_amount, the exact USDT to send. pay_amount is amount plus the network and processing fee, which the customer pays, so you receive the full amount. The address stays open for 60 minutes (expires_at); a deposit that arrives later is still confirmed. A payment short of pay_amount is not marked paid.
Who pays the transaction fee is set per website under Dashboard → Websites & webhook. With fee_bearer customer, the customer pays pay_amount (the amount plus the fee: render the QR string or show this amount) and you receive exactly amount; the webhook and your dashboard show amount. Tell your customers the price includes a service fee. Otherwise pay_amount equals amount for QRIS. A change applies to new payments only.
merchant_reference and the same amount/method returns the original transaction with 200 OK instead of creating a duplicate. Reusing the reference with a different amount or method returns 409 DUPLICATE_REFERENCE. Sandbox and live keys are separate: the same merchant_reference can be used once in each, and a key never sees the other environment's transactions.Turn on the hosted payment page for a website under Dashboard → Websites & webhook. A transaction for that website (named in website, matched by return_url, or your default website) comes with a checkout_url (null otherwise): a PAYWISE-hosted page that shows your customer the QR code (QRIS) or the deposit address and exact amount (USDT), a countdown, and the payment status, updating by itself. Redirect your customer there, or open it in a new tab. You can still render qr_string or pay_address yourself instead.
After the payment is paid, expired or failed, the page sends the customer back to the return URL you set for your website under Dashboard → Websites & webhook (the default website, or the one named in website). Send return_url to override it for one payment: it must be https and on one of those domains (or a subdomain), otherwise 422 RETURN_URL_NOT_ALLOWED. PAYWISE appends the outcome to it:
https://shop.example.com/thanks?reference=PW20260921143022AB3F&merchant_reference=ORDER-1042&status=paid
status in that URL is only for your page's message. Anyone can type it, so never fulfil an order on it: wait for the payment.paid webhook or check with GET /api/v1/transactions/{reference}.{reference} is the PAYWISE reference returned when you created the transaction, not your own merchant_reference.
404 NOT_FOUND, not 403, so it does not reveal that the reference exists.Your transactions in the key's environment, newest first: 20 per page, up to 100 with per_page. All filters are optional and can be combined. Each item is the same object as GET /api/v1/transactions/{reference} returns.
| Field | Notes |
|---|---|
| status | pending, paid, failed or expired. |
| payment_method | QRIS or USDT. |
| date_from | YYYY-MM-DD. First day (UTC+7) the transaction was created. |
| date_to | YYYY-MM-DD. Last day (UTC+7), inclusive. |
| merchant_reference | Your order reference, exact match. |
| customer_id | Your customer id, exact match. |
| per_page | 1–100, default 20. |
| page | Page number, default 1. |
{
"success": true,
"data": [
{
"reference": "PW20260921143022AB3F",
"merchant_reference": "ORDER-1042",
"payment_method": "QRIS",
"status": "paid",
"amount": 250000,
"currency": "IDR",
"…": "…"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
}
meta gives the current page, the page size, the total number of matching transactions and the last page.
The same values are used consistently across the database, this API, the merchant dashboard, and the admin dashboard.
Withdrawals
Request a withdrawal of your settled funds from your server: the same request as Dashboard → Withdrawals, with the same minimum, maximum, fee and available funds, and the same automatic payout. Only a live key can request one (a sandbox key gets 403 LIVE_KEY_REQUIRED); there is no email code, so the live key alone authorises the payout. Keep it on your server and consider an allowed-IP list on the API Keys page.
amount is what arrives at the destination. The withdrawal fee (Rp3.000 for QRIS, 1.00 USDT for USDT) is added on top and taken from your available funds; the response shows amount (taken), fee and payout_amount (sent). Limits per withdrawal: Rp25.000 – Rp20.000.000 for QRIS, 10.00 USDT – 10,000.00 USDT for USDT.
| Field | Notes |
|---|---|
| payment_method | Required. Which funds to withdraw: QRIS (paid out to a bank account in IDR) or USDT (sent to a BEP20 wallet). |
| amount | Required. What arrives at the destination, in the rail's currency (up to 2 decimals). |
| bank_code | QRIS only, required. A code from the list below: 3 digits for a bank, or DANA, OVO, GOPAY or SHOPEEPAY for an e-wallet (account_number is then the 08… phone number). An e-wallet takes at most IDR 10,000,000 per withdrawal and has no owner name check: a wrong number is still paid. |
| account_number | QRIS only, required. 5 to 30 digits. |
| wallet_address | USDT only, required. A BEP20 address: 0x followed by 40 hex characters. |
| network | USDT only, required: BEP20. |
200, so a timed-out request is safe to retry. The same key with a different amount, method or destination returns 409 DUPLICATE_REQUEST.curl -X POST https://paywise.asia/api/v1/withdrawals \
-H "Authorization: Bearer pw_live_…" \
-H "Idempotency-Key: payout-2026-09-27-001" \
-H "Content-Type: application/json" \
-d '{"payment_method":"QRIS","amount":1500000,"bank_code":"014","account_number":"1234567890"}'
{
"success": true,
"data": {
"reference": "PWD20260927090112C4DQ",
"payment_method": "QRIS",
"environment": "live",
"payout_method": "bank_transfer",
"currency": "IDR",
"network": null,
"amount": 1503000,
"fee": 3000,
"payout_amount": 1500000,
"status": "requested",
"destination": {
"bank_code": "014",
"bank_name": "BCA",
"account_number": "1234567890"
},
"reason": null,
"requested_at": "2026-09-27T02:01:12+00:00",
"completed_at": null,
"closed_at": null
}
}
422 INSUFFICIENT_FUNDS means the amount plus the fee is more than your settled, not yet withdrawn funds of that method; error.available is the largest amount you can request now. QRIS funds settle in batches a few times a day; USDT funds settle when paid.
A withdrawal starts as requested and then moves through approved and processing to completed, or ends failed or rejected (with reason). Follow it with the withdrawal.* webhooks or with the endpoints below.
Your withdrawals in the key's environment, newest first, with the same paging and meta as the transaction list. Filters (all optional): status (requested, approved, processing, completed, failed, rejected), payment_method, date_from and date_to (YYYY-MM-DD, UTC+7).
{reference} is the PWD… reference returned when the withdrawal was requested. Another merchant's withdrawal, or one from the other environment, returns 404 NOT_FOUND.
Your funds per payment method in the key's environment, as on the Withdrawals page: available to withdraw now, waiting for the next settlement batch, the most one withdrawal can pay out now (max_withdrawal) and each withdrawal's limits and fee.
{
"success": true,
"data": {
"environment": "live",
"methods": {
"QRIS": {
"currency": "IDR",
"available": 1250000,
"awaiting_settlement": 480000,
"next_settlement_at": "2026-09-28T08:00:00+00:00",
"next_settlement_amount": 480000,
"max_withdrawal": 1245000,
"withdrawal": {
"min": 25000,
"max": 20000000,
"fee": 5000
}
},
"USDT": {
"…": "…"
}
}
}
}
Webhooks
Configure a webhook URL and events from Dashboard → Websites & webhook. When a transaction's status changes to a subscribed event, PAYWISE sends:
POST <your webhook_url> Content-Type: application/json X-PAYWISE-Signature: <hex-encoded HMAC-SHA256>
HMAC-SHA256(raw_json_body, your_webhook_secret) (the secret is shown on the Websites & webhook page; one secret for every webhook) and comparing it to the X-PAYWISE-Signature header. Events: payment.paid · payment.failed · payment.expired.
Webhooks are sent from 217.217.253.90 · 2407:3640:2358:7339::1. If your firewall only accepts known addresses, allow these; still verify the signature on every delivery. Changes to this list are announced in advance.
Several websites on different servers? Give a website its own webhook URL under Dashboard → Websites & webhook. Payments for that website (the one named in website, matched by return_url, or your default website) are then sent there; everything else still goes to your main webhook URL. All of them are signed with the same secret. A website that got its own secret earlier keeps it until you switch it to the main secret on that page.
Every delivery attempt is logged on the Websites & webhook page with the event, the attempt number, the real HTTP status your endpoint returned (empty on a timeout) and up to 2 KB of its response. Failed deliveries are retried automatically, and you can resend one from the transaction page.
Withdrawal events (withdrawal.completed · withdrawal.failed · withdrawal.rejected) go to your main webhook URL when a withdrawal is completed, fails or is rejected. A failed or rejected amount can be withdrawn again. Subscribe on the Websites & webhook page. reason gives the reason for a failure or rejection and is null when completed.
{
"event": "withdrawal.completed",
"data": {
"reference": "PWD20260925090112C4DQ",
"payment_method": "QRIS",
"environment": "live",
"payout_method": "bank_transfer",
"currency": "IDR",
"network": null,
"amount": 1500000,
"fee": 5000,
"payout_amount": 1495000,
"status": "completed",
"reason": null,
"requested_at": "2026-09-25T02:01:12+00:00",
"completed_at": "2026-09-25T02:03:40+00:00",
"closed_at": null
}
}
Used internally by PAYWISE's payment network, not by merchants. Every inbound payment notification is authenticated (secret token or HMAC signature), matched to one live transaction, and then confirmed server to server; the notification's own status never marks a payment paid. A rail that is not available returns 503 PROVIDER_NOT_CONNECTED.
Usually replies within a minute