Open-Loop Wallet
The open-loop wallet gives every user their own real, per-user balance at an external money provider (top-up by paying a Virtual Account, withdraw to a bank, pay a QRIS merchant, or cash out to an e-wallet). It is provider-agnostic: the routes, request/response shapes, and persisted ledger carry a provider discriminator so a second provider can be added later without changing this contract. Singapay is the first (and currently only) wired provider — clients never talk to it directly or authenticate against it; Moria operates the account on the user’s behalf.
| Property | Value |
|---|---|
| Base URL | {HOST}/v1 |
| Auth | Bearer JWT (header Authorization) or cookie access_token |
| Content-Type | application/json |
| Idempotency | X-Idempotency-Key required on every money-mover (see cover note) |
| Error envelope | { "message": string | string[], "statusCode": number, "error": string } |
| Validation | Global ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400 |
| Related modules | accounts, payments, payment-gateway, transactions, withdrawal |
| Document version | v1 · 2026-06-10 |
| Source synced | feat/singapay-wallet (PR #244) @ 22209fc6 — not yet merged to dev |
| Audience | Internal FE devs (mobile + web) |
Summary
Section titled “Summary”A user’s wallet account is provisioned automatically when they verify their email (an event-driven listener creates the provider account in the background). Money-movers (/onboarding, /topup, /disbursement, /qris/pay, /ewallet/cashout) also lazily ensure the account exists, so they self-heal if provisioning was missed. Read endpoints (/balance, /history) are read-only — they return 404 if the user has no account yet rather than creating one.
Almost every money movement is asynchronous: the call returns status: "pending" and the row finalises to settled / failed when the provider delivers a signed webhook. Balance is always a live read from the provider, never cached locally. All monetary values are decimal strings in IDR.
| Method | Path | Auth | Idempotent | Summary |
|---|---|---|---|---|
| POST | /v1/wallet/open-loop/onboarding | bearer | yes | Ensure the user’s wallet account exists |
| GET | /v1/wallet/open-loop/balance | bearer | — | Live per-user balance |
| GET | /v1/wallet/open-loop/payment-methods | bearer | — | Submittable top-up banks + e-wallet vendors |
| POST | /v1/wallet/open-loop/topup | bearer | yes | Create a VA to top up (money-in) |
| POST | /v1/wallet/open-loop/disbursement/check-beneficiary | bearer | — | Verify a bank beneficiary |
| POST | /v1/wallet/open-loop/disbursement | bearer | yes | Withdraw to a bank (money-out) |
| POST | /v1/wallet/open-loop/qris/decode | bearer | — | Decode a scanned QRIS |
| POST | /v1/wallet/open-loop/qris/pay | bearer | yes | Pay a QRIS merchant (money-out) |
| GET | /v1/wallet/open-loop/qris/{transactionId}/status | bearer | — | Poll a QRIS payment |
| POST | /v1/wallet/open-loop/ewallet/inquiry | bearer | — | Verify an e-wallet beneficiary |
| POST | /v1/wallet/open-loop/ewallet/cashout | bearer | yes | Cash out to an e-wallet (money-out) |
| GET | /v1/wallet/open-loop/history | bearer | — | Paginated statements |
POST /v1/wallet/open-loop/onboarding bearer
Section titled “POST /v1/wallet/open-loop/onboarding ”Ensure the caller has a wallet account. Looks it up; creates it at the provider if absent; otherwise returns the existing row. Safe to call repeatedly. Requires X-Idempotency-Key so concurrent first-clicks cannot create two accounts.
Request
Section titled “Request”No body. Header X-Idempotency-Key required.
Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "Onboarding successful", "data": { "account_id": "01KTR88Q3PE8QZQXDT8GM7J2HQ", "status": "active", "name": "Open Loop Test", "phone": "+6281299887766", "created_at": "2026-06-10T07:52:05.000Z" }}Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 | Missing/invalid X-Idempotency-Key |
401 | Bearer/cookie invalid |
409 | Idempotency key reused with a different body / for a different operation |
502 | Provider upstream unreachable |
GET /v1/wallet/open-loop/balance bearer
Section titled “GET /v1/wallet/open-loop/balance ”Live per-user balance. Never cached. Read-only — does not auto-create the account.
Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "Balance fetched", "data": { "account_id": "01KTR88Q3PE8QZQXDT8GM7J2HQ", "available": { "value": "97500.00", "currency": "IDR" }, "pending": { "value": "0.00", "currency": "IDR" }, "held": { "value": "0.00", "currency": "IDR" }, "total": { "value": "97500.00", "currency": "IDR" } }}available is spendable now; pending is settling (e.g. T+1 on merchants without auto-settle); held is authorised-but-not-captured. Values are decimal strings — use a decimal library for arithmetic.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
401 | Bearer/cookie invalid |
404 | User has no wallet account yet — call /onboarding first (or wait for async provisioning) |
502 | Provider upstream unreachable |
GET /v1/wallet/open-loop/payment-methods bearer
Section titled “GET /v1/wallet/open-loop/payment-methods ”The options the money-movers will actually accept — topup_banks (for /topup) and ewallet_vendors (for /ewallet/cashout). Sourced from the backend enums (single source of truth) so the picker can never drift from validation. Static catalog, not user-scoped, does not touch the provider.
Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "Payment methods fetched", "data": { "topup_banks": [ { "code": "BRI", "name": "Bank BRI" }, { "code": "BNI", "name": "Bank BNI" }, { "code": "DANAMON", "name": "Bank Danamon" }, { "code": "MAYBANK", "name": "Maybank" }, { "code": "BCA", "name": "Bank BCA" } ], "ewallet_vendors": [ { "code": "DANA", "name": "DANA" }, { "code": "SHOPEEPAY", "name": "ShopeePay" }, { "code": "OVO", "name": "OVO" }, { "code": "GOPAY", "name": "GoPay" }, { "code": "SINGAPAY", "name": "SingaPay" } ] }}POST /v1/wallet/open-loop/topup bearer
Section titled “POST /v1/wallet/open-loop/topup ”Create a Virtual Account the user pays into from their own bank app. Settlement is asynchronous: the row starts pending and flips to settled on the va-transaction webhook.
Request body — TopupRequestDto
Section titled “Request body — TopupRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
amount | decimal string | yes | IDR, min 10000. Preserved as-is (no number coercion). |
bank_code | enum | yes | One of payment-methods.topup_banks[].code (BRI, BNI, DANAMON, MAYBANK, BCA). |
Header X-Idempotency-Key required.
{ "amount": "100000", "bank_code": "BRI" }Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "Top-up VA created", "data": { "transaction_id": "bd091eb2-a36f-42de-a004-16f31ce3b275", "va_number": "5588865403574395", "bank_code": "BRI", "amount": "100000", "status": "pending", "expired_at": null, "reference_number": "f00211ba1aed5eee719c2ee2" }}Show va_number + bank_code to the user. The credit lands (minus any VA fee) once the webhook settles — re-read /balance after.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 | Missing/invalid idempotency key or amount below 10,000 IDR |
401 | Bearer/cookie invalid |
409 | Idempotency key reused (different body / different operation) |
502 | Provider upstream unreachable |
POST /v1/wallet/open-loop/disbursement/check-beneficiary bearer
Section titled “POST /v1/wallet/open-loop/disbursement/check-beneficiary ”Verify a bank account before withdrawing. Read-only — writes no row.
Request body — DisbursementBankInquiryRequestDto
Section titled “Request body — DisbursementBankInquiryRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
bank_code | string | yes | 3-digit bank number or 8-char SWIFT (e.g. 002). |
bank_account_number | string | yes | Digits only, 6–20. |
{ "bank_code": "002", "bank_account_number": "888801000157508" }Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "Beneficiary verified", "data": { "bank_code": "002", "bank_account_number": "888801000157508", "bank_account_name": "JOHN DOE", "fee": { "value": "2500.00", "currency": "IDR" } }}fee is null when the provider does not surface one on inquiry.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
401 | Bearer/cookie invalid |
422 | Provider rejected the beneficiary lookup |
502 | Provider upstream unreachable |
POST /v1/wallet/open-loop/disbursement bearer
Section titled “POST /v1/wallet/open-loop/disbursement ”Withdraw from the wallet to an external bank account. Returns pending (occasionally settled/failed if the provider finalises synchronously). Final state arrives via the disbursement webhook.
Request body — DisbursementRequestDto
Section titled “Request body — DisbursementRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
amount | decimal string | yes | IDR, min 10000. |
bank_code | string | yes | 3-digit bank number or 8-char SWIFT. |
bank_account_number | string | yes | Digits only, 6–20. |
notes | string | optional | Free-form, ≤50 chars. |
Header X-Idempotency-Key required.
{ "amount": "50000.00", "bank_code": "002", "bank_account_number": "888801000157508", "notes": "payroll bulan ini"}Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "Disbursement created", "data": { "transaction_id": "6665a44d-376b-4774-9a79-b14c4850ab99", "reference_number": "a1b2c3d4e5f6071829304152", "provider_reference": "101222025122910292195055674", "status": "pending", "amount": { "value": "50000.00", "currency": "IDR" } }}Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 | Missing/invalid idempotency key or amount below 10,000 IDR |
401 | Bearer/cookie invalid |
409 | Idempotency key reused (different body / different operation) |
422 | Provider rejected the transfer (e.g. insufficient balance, issuer declined) |
502 | Provider upstream unreachable |
POST /v1/wallet/open-loop/qris/decode bearer
Section titled “POST /v1/wallet/open-loop/qris/decode ”Decode a scanned QRIS payload to a merchant + amount so the UI can render a confirm sheet. Read-only.
Request body — QrisDecodeRequestDto
Section titled “Request body — QrisDecodeRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
qr_content | string | yes | Raw QRIS payload scanned by the client (static or dynamic). |
{ "qr_content": "00020101021126670016ID.CO.SINGAPAY.WWW0118936009140000000000..." }Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "QR decoded", "data": { "merchant_name": "WARUNG POJOK", "merchant_id": "ID1023456789012", "amount": { "value": "25000.00", "currency": "IDR" }, "fee": null, "qr_content": "00020101021126670016ID.CO.SINGAPAY.WWW0118936009140000000000..." }}amount is null for a static (open-amount) QR — the user enters it. Pass qr_content straight back into /qris/pay.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
401 | Bearer/cookie invalid |
422 | Provider rejected the QR content |
502 | Provider upstream unreachable |
POST /v1/wallet/open-loop/qris/pay bearer
Section titled “POST /v1/wallet/open-loop/qris/pay ”Pay the scanned merchant. Returns pending; final state arrives via the qris-issuer webhook (or poll the status endpoint).
Request body — QrisPayRequestDto
Section titled “Request body — QrisPayRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
qr_content | string | yes | The exact qr_content from /qris/decode. |
amount | decimal string | yes | IDR. For a dynamic QR, match the decoded amount. |
Header X-Idempotency-Key required.
{ "qr_content": "00020101021126670016ID.CO.SINGAPAY.WWW...", "amount": "25000.00" }Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "QRIS payment submitted", "data": { "transaction_id": "c3a1...", "reference_number": "a1b2c3d4e5f6071829304152", "provider_reference": null, "status": "pending", "amount": { "value": "25000.00", "currency": "IDR" } }}Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 | Missing/invalid idempotency key or non-positive amount |
401 | Bearer/cookie invalid |
409 | Idempotency key reused (different body / different operation) |
422 | Provider rejected the payment (insufficient balance, invalid QR) |
502 | Provider upstream unreachable |
GET /v1/wallet/open-loop/qris/{transactionId}/status bearer
Section titled “GET /v1/wallet/open-loop/qris/{transactionId}/status ”Poll a QRIS payment owned by the caller. Terminal rows return immediately; a still-pending row triggers a provider status poll and is optimistically advanced when the provider reports terminal (the webhook stays authoritative for later updates).
Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "QRIS status", "data": { "transaction_id": "c3a1...", "status": "settled", "amount": { "value": "25000.00", "currency": "IDR" }, "provider_reference": "sg-qris-101222025122910292195055674" }}Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
401 | Bearer/cookie invalid |
404 | No QRIS payment with that id for this user |
502 | Provider upstream unreachable |
POST /v1/wallet/open-loop/ewallet/inquiry bearer
Section titled “POST /v1/wallet/open-loop/ewallet/inquiry ”Verify an e-wallet beneficiary and read per-vendor limits before cashing out. Read-only.
Request body — EwalletInquiryRequestDto
Section titled “Request body — EwalletInquiryRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
vendor | enum | yes | One of payment-methods.ewallet_vendors[].code (DANA, SHOPEEPAY, OVO, GOPAY, SINGAPAY). |
account_number | string | yes | The e-wallet phone/account number. |
amount | decimal string | optional | Used to preview a vendor fee when applicable. |
{ "vendor": "GOPAY", "account_number": "081291335215", "amount": "20000" }Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "E-wallet beneficiary verified", "data": { "vendor": "GOPAY", "account_number": "081291335215", "beneficiary_name": "Gopay Wallet Simulator Account", "min_amount": { "value": "1000.00", "currency": "IDR" }, "max_amount": { "value": "50000000.00", "currency": "IDR" }, "fee": null }}Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
401 | Bearer/cookie invalid |
422 | Provider rejected the beneficiary lookup |
502 | Provider upstream unreachable |
POST /v1/wallet/open-loop/ewallet/cashout bearer
Section titled “POST /v1/wallet/open-loop/ewallet/cashout ”Cash out from the wallet to an e-wallet account. Returns pending; final state arrives via the ewallet-topup webhook.
Request body — EwalletCashoutRequestDto
Section titled “Request body — EwalletCashoutRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
vendor | enum | yes | DANA, SHOPEEPAY, OVO, GOPAY, SINGAPAY. |
account_number | string | yes | The e-wallet phone/account number. |
amount | decimal string | yes | IDR, ≥ the vendor’s min_amount. |
notes | string | optional | Free-form. |
Header X-Idempotency-Key required.
{ "vendor": "GOPAY", "account_number": "081291335215", "amount": "20000", "notes": "cash out"}Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "E-wallet cash-out submitted", "data": { "transaction_id": "6665a44d-376b-4774-9a79-b14c4850ab99", "reference_number": "977feb89db32ff63d464c566", "provider_reference": "285203332026061014592397359191", "status": "pending", "vendor": "GOPAY", "account_number": "081291335215", "beneficiary_name": "Gopay Wallet Simulator Account", "amount": { "value": "20000", "currency": "IDR" }, "notes": "cash out", "is_idempotent_replay": false }}The wallet is debited amount + the vendor fee. is_idempotent_replay: true means this was a cached replay of a prior identical call.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 | Missing/invalid idempotency key or amount below the vendor minimum |
401 | Bearer/cookie invalid |
409 | Idempotency key reused (different body / different operation) |
422 | Provider rejected the cash-out (insufficient balance, invalid account) |
502 | Provider upstream unreachable |
GET /v1/wallet/open-loop/history bearer
Section titled “GET /v1/wallet/open-loop/history ”Paginated statements for the caller — a pass-through over the provider’s statement API. Object-wrapped (data.items + data.pagination), never a bare array. Read-only.
Query parameters — HistoryQueryDto
Section titled “Query parameters — HistoryQueryDto”| Param | Type | Default | Notes |
|---|---|---|---|
from | ISO-8601 date/datetime | — | Inclusive lower bound. |
to | ISO-8601 date/datetime | — | Inclusive upper bound. |
page | integer | 1 | |
per_page | integer (1–100) | 25 | |
kind | string | — | Filter by upstream kind (e.g. disbursement). |
Response — 200 OK
Section titled “Response — 200 OK”{ "status": "success", "statusCode": 200, "message": "History fetched", "data": { "items": [ { "id": "6601K3GDEQVPHBBP4GRYQADG0KXT", "upstream_kind": "disbursement", "internal_kind": "disbursement", "status": null, "amount": { "value": "50000.00", "currency": "IDR" }, "balance_after": { "value": "47500.00", "currency": "IDR" }, "reference_number": "a1b2c3d4e5f6071829304152", "provider_reference": "6601K3GDEQVPHBBP4GRYQADG0KXT", "processed_at": "2026-06-10T07:59:29.000Z" } ], "pagination": { "page": 1, "per_page": 25, "total_items": null, "total_pages": null } }}items reflects the provider’s statement ledger (separate from the per-call rows). total_items/total_pages are null when the provider does not return counts.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
401 | Bearer/cookie invalid |
404 | User has no wallet account yet |
502 | Provider upstream unreachable |
Reference
Section titled “Reference”Settlement model
Section titled “Settlement model”Most operations are two-phase: an API call (returns pending) then a signed provider webhook that finalises the ledger row.
| Operation | Settles via webhook event |
|---|---|
topup | va-transaction → /webhook/transaction |
disbursement | disbursement → /webhook/disbursement |
qris/pay | qris-issuer → /webhook/disbursement |
ewallet/cashout | ewallet-topup → /webhook/disbursement |
status lifecycle: pending → settled | failed | expired | canceled. Webhooks are first-write-wins (a duplicate event does not re-settle or double-credit), and an unknown upstream status leaves the row pending rather than failing it.
- Top-up banks (
bank_codeon/topup):BRI,BNI,DANAMON,MAYBANK,BCA. - E-wallet vendors (
vendor):DANA,SHOPEEPAY,OVO,GOPAY,SINGAPAY. - Transaction status:
pending,settled,failed,expired,canceled. - Account status:
active,inactive,pending,rejected.
Always source the bank/vendor lists from GET /payment-methods rather than hardcoding — it is the single source of truth and will grow as providers are added.
Money shape
Section titled “Money shape”Every monetary field is an object { "value": "<decimal string>", "currency": "IDR" } (balance, fees, history) or a bare decimal string on request bodies (amount). Never parse these as floats — use a decimal library.
Standard error envelope
Section titled “Standard error envelope”{ "message": "Idempotency key reused for a different operation", "statusCode": 409, "error": "Conflict"}Common HTTP codes
Section titled “Common HTTP codes”400validation (missing/invalid idempotency key, amount below minimum)401Bearer/cookie invalid404no wallet account yet (read endpoints) / unknown QRIS transaction id409idempotency conflict (key reused with a different body or operation)422provider declined the money movement (insufficient balance, issuer declined, invalid beneficiary)502provider upstream unreachable500internal — generic toast
Integration notes
Section titled “Integration notes”- Generate a fresh
X-Idempotency-Keyper user intent; reuse the same key when retrying that same intent. - Treat
pendingas in-flight — confirm success only after the webhook (or QRIS status poll) reports terminal; re-read/balanceto reflect credits/debits. - Onboard happens automatically on email verification; you generally do not need to call
/onboardingexplicitly, but it is safe to.