Skip to content

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.

PropertyValue
Base URL{HOST}/v1
AuthBearer JWT (header Authorization) or cookie access_token
Content-Typeapplication/json
IdempotencyX-Idempotency-Key required on every money-mover (see cover note)
Error envelope{ "message": string | string[], "statusCode": number, "error": string }
ValidationGlobal ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400
Related modulesaccounts, payments, payment-gateway, transactions, withdrawal
Document versionv1 · 2026-06-10
Source syncedfeat/singapay-wallet (PR #244) @ 22209fc6 — not yet merged to dev
AudienceInternal FE devs (mobile + web)

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.

MethodPathAuthIdempotentSummary
POST/v1/wallet/open-loop/onboardingbeareryesEnsure the user’s wallet account exists
GET/v1/wallet/open-loop/balancebearerLive per-user balance
GET/v1/wallet/open-loop/payment-methodsbearerSubmittable top-up banks + e-wallet vendors
POST/v1/wallet/open-loop/topupbeareryesCreate a VA to top up (money-in)
POST/v1/wallet/open-loop/disbursement/check-beneficiarybearerVerify a bank beneficiary
POST/v1/wallet/open-loop/disbursementbeareryesWithdraw to a bank (money-out)
POST/v1/wallet/open-loop/qris/decodebearerDecode a scanned QRIS
POST/v1/wallet/open-loop/qris/paybeareryesPay a QRIS merchant (money-out)
GET/v1/wallet/open-loop/qris/{transactionId}/statusbearerPoll a QRIS payment
POST/v1/wallet/open-loop/ewallet/inquirybearerVerify an e-wallet beneficiary
POST/v1/wallet/open-loop/ewallet/cashoutbeareryesCash out to an e-wallet (money-out)
GET/v1/wallet/open-loop/historybearerPaginated 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.

No body. Header X-Idempotency-Key required.

{
"status": "success",
"statusCode": 200,
"message": "Onboarding successful",
"data": {
"account_id": "01KTR88Q3PE8QZQXDT8GM7J2HQ",
"status": "active",
"name": "Open Loop Test",
"email": "[email protected]",
"phone": "+6281299887766",
"created_at": "2026-06-10T07:52:05.000Z"
}
}
StatusWhen it occurs
400Missing/invalid X-Idempotency-Key
401Bearer/cookie invalid
409Idempotency key reused with a different body / for a different operation
502Provider 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.

{
"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.

StatusWhen it occurs
401Bearer/cookie invalid
404User has no wallet account yet — call /onboarding first (or wait for async provisioning)
502Provider 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.

{
"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.

FieldTypeRequiredNotes
amountdecimal stringyesIDR, min 10000. Preserved as-is (no number coercion).
bank_codeenumyesOne of payment-methods.topup_banks[].code (BRI, BNI, DANAMON, MAYBANK, BCA).

Header X-Idempotency-Key required.

{ "amount": "100000", "bank_code": "BRI" }
{
"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.

StatusWhen it occurs
400Missing/invalid idempotency key or amount below 10,000 IDR
401Bearer/cookie invalid
409Idempotency key reused (different body / different operation)
502Provider 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”
FieldTypeRequiredNotes
bank_codestringyes3-digit bank number or 8-char SWIFT (e.g. 002).
bank_account_numberstringyesDigits only, 6–20.
{ "bank_code": "002", "bank_account_number": "888801000157508" }
{
"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.

StatusWhen it occurs
401Bearer/cookie invalid
422Provider rejected the beneficiary lookup
502Provider 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.

FieldTypeRequiredNotes
amountdecimal stringyesIDR, min 10000.
bank_codestringyes3-digit bank number or 8-char SWIFT.
bank_account_numberstringyesDigits only, 6–20.
notesstringoptionalFree-form, ≤50 chars.

Header X-Idempotency-Key required.

{
"amount": "50000.00",
"bank_code": "002",
"bank_account_number": "888801000157508",
"notes": "payroll bulan ini"
}
{
"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" }
}
}
StatusWhen it occurs
400Missing/invalid idempotency key or amount below 10,000 IDR
401Bearer/cookie invalid
409Idempotency key reused (different body / different operation)
422Provider rejected the transfer (e.g. insufficient balance, issuer declined)
502Provider 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.

FieldTypeRequiredNotes
qr_contentstringyesRaw QRIS payload scanned by the client (static or dynamic).
{ "qr_content": "00020101021126670016ID.CO.SINGAPAY.WWW0118936009140000000000..." }
{
"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.

StatusWhen it occurs
401Bearer/cookie invalid
422Provider rejected the QR content
502Provider 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).

FieldTypeRequiredNotes
qr_contentstringyesThe exact qr_content from /qris/decode.
amountdecimal stringyesIDR. For a dynamic QR, match the decoded amount.

Header X-Idempotency-Key required.

{ "qr_content": "00020101021126670016ID.CO.SINGAPAY.WWW...", "amount": "25000.00" }
{
"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" }
}
}
StatusWhen it occurs
400Missing/invalid idempotency key or non-positive amount
401Bearer/cookie invalid
409Idempotency key reused (different body / different operation)
422Provider rejected the payment (insufficient balance, invalid QR)
502Provider 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).

{
"status": "success",
"statusCode": 200,
"message": "QRIS status",
"data": {
"transaction_id": "c3a1...",
"status": "settled",
"amount": { "value": "25000.00", "currency": "IDR" },
"provider_reference": "sg-qris-101222025122910292195055674"
}
}
StatusWhen it occurs
401Bearer/cookie invalid
404No QRIS payment with that id for this user
502Provider 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.

FieldTypeRequiredNotes
vendorenumyesOne of payment-methods.ewallet_vendors[].code (DANA, SHOPEEPAY, OVO, GOPAY, SINGAPAY).
account_numberstringyesThe e-wallet phone/account number.
amountdecimal stringoptionalUsed to preview a vendor fee when applicable.
{ "vendor": "GOPAY", "account_number": "081291335215", "amount": "20000" }
{
"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
}
}
StatusWhen it occurs
401Bearer/cookie invalid
422Provider rejected the beneficiary lookup
502Provider 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.

FieldTypeRequiredNotes
vendorenumyesDANA, SHOPEEPAY, OVO, GOPAY, SINGAPAY.
account_numberstringyesThe e-wallet phone/account number.
amountdecimal stringyesIDR, ≥ the vendor’s min_amount.
notesstringoptionalFree-form.

Header X-Idempotency-Key required.

{
"vendor": "GOPAY",
"account_number": "081291335215",
"amount": "20000",
"notes": "cash out"
}
{
"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.

StatusWhen it occurs
400Missing/invalid idempotency key or amount below the vendor minimum
401Bearer/cookie invalid
409Idempotency key reused (different body / different operation)
422Provider rejected the cash-out (insufficient balance, invalid account)
502Provider 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.

ParamTypeDefaultNotes
fromISO-8601 date/datetimeInclusive lower bound.
toISO-8601 date/datetimeInclusive upper bound.
pageinteger1
per_pageinteger (1–100)25
kindstringFilter by upstream kind (e.g. disbursement).
{
"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.

StatusWhen it occurs
401Bearer/cookie invalid
404User has no wallet account yet
502Provider upstream unreachable

Most operations are two-phase: an API call (returns pending) then a signed provider webhook that finalises the ledger row.

OperationSettles via webhook event
topupva-transaction/webhook/transaction
disbursementdisbursement/webhook/disbursement
qris/payqris-issuer/webhook/disbursement
ewallet/cashoutewallet-topup/webhook/disbursement

status lifecycle: pendingsettled | 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_code on /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.

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.

{
"message": "Idempotency key reused for a different operation",
"statusCode": 409,
"error": "Conflict"
}
  • 400 validation (missing/invalid idempotency key, amount below minimum)
  • 401 Bearer/cookie invalid
  • 404 no wallet account yet (read endpoints) / unknown QRIS transaction id
  • 409 idempotency conflict (key reused with a different body or operation)
  • 422 provider declined the money movement (insufficient balance, issuer declined, invalid beneficiary)
  • 502 provider upstream unreachable
  • 500 internal — generic toast
  • Generate a fresh X-Idempotency-Key per user intent; reuse the same key when retrying that same intent.
  • Treat pending as in-flight — confirm success only after the webhook (or QRIS status poll) reports terminal; re-read /balance to reflect credits/debits.
  • Onboard happens automatically on email verification; you generally do not need to call /onboarding explicitly, but it is safe to.