Wallet
Testing

Testing

Who calls these endpoints?External API (s2s)
Base path
/api/v1/wallet
Identity
Ed25519 request signature — X-Key-Id, X-Date, X-Nonce, Digest (when there is a body), X-Signature. ±5 min clock window, single-use nonce, optional per-key IP allow-list.
Authorization
The key must carry the wallet product and the wallet permission the call requires; a call without it gets 403 WALLET_INSUFFICIENT_SCOPE. Permissions per endpoint: Key permissions.
Cross-surface
These endpoints are the s2s surface — call them from your own server with a signed request.

Test your wallet integration in two layers: first signing and contract, then money flow.

TEST and LIVE keys

Your API keys carry an environment: TEST or LIVE. The environment is embedded in the key's identity and cannot be crossed.

⚠️

Using a TEST key against a LIVE resource (or the reverse) is rejected. "I accidentally tested in production" is prevented by this split — you do not declare the environment in the request; the key does.

Create separate keys for both environments in the panel and select between them with an environment variable. Never try to reuse one key across both.

Layer 1 — signing and contract

Prove your signing is correct before touching money:

Start with ping

GET /api/v1/wallet/ping

It has no body and needs no Idempotency-Key; the key needs a set-up permission (wallet:program:manage or wallet:enduser:manage). Do not try anything else until this returns 200.

Try an endpoint with a query string

GET /api/v1/wallet/operations?limit=1

This proves you put the query string in the second line of the canonical string — the most common signing mistake.

Try an endpoint with a body

POST /api/v1/wallet/wallets/lookup

This proves your Digest header is right: the body goes out as compact JSON and the digest is computed over exactly that string.

What the error codes mean: Authentication.

Layer 2 — closed-loop money flow

These scenarios need no connected external provider and can be exercised end to end:

ScenarioWhat it proves
Create user → create again with the same phonePhone deduplication
Open an account → read the balanceAccount/currency model
Start card top-up → open the form → completeTop-up flow and webhook
Resend the top-up with the same Idempotency-KeyIdempotency behaviour
W2W quote → init → completeTransfer flow, OTP, integrityHash
Call complete with a wrong nonceThe verification gate
Transfer more than the balancesufficientBalance and rejection behaviour
Transfer above the limitlimitBlocked behaviour
Wallet payment → customer approvalPayment flow
Generate QR → parse → payQR flow
Verify a webhook signatureYour signature computation

Do run the idempotency test: sending the same key twice must not create a second operation — it must return the first response. Going live without proving this means risking a double money movement the first time the network hiccups.

Simulators and scenarios

Deterministic simulators sit behind the external provider endpoints. Their purpose is not to demonstrate the happy path but to let you exercise the failure paths: timeouts, late approvals, duplicates and hangs, without a real institution behind them.

There are five:

SimulatorWhat it stands in forScenario selector
TestBankThe bank integration (withdrawals, IBAN transfers, IBAN bulk payouts, IBAN ownership checks)Last 4 digits of the destination IBAN
TestATMThe ATM / QR cash-out networkLast 4 characters of the cash-out code id
TestBillerA biller (inquiry + payment)Last 4 digits of the subscriber number
TestIssuerThe card issuer (minting + authorization)See the note below — not the card number
TestExchangeCrypto swap ratesNo scenarios — a fixed, deterministic price table

Binding a simulator to your program

Simulators are first-class integrations in the panel: on the Integrations screen you will see their cards and bind them to your program exactly as you would a real institution. There is no separate "test environment" and no second account — you bind it and it works.

Panel cardCategoryWhat it opens
Test BankBank integrationBank transfer top-up, bank withdrawal, IBAN transfer, IBAN-targeted bulk payouts
Test ATM NetworkATM networkATM and QR cash-out
Test BillerBillerBill inquiry and payment
Test Card IssuerCard issuerCard minting, authorization, settle, freeze/cancel
Test ExchangeCryptoFiat↔crypto swap

Every scenario is on — there is no switch to flip and no setting to request. Each scenario in the tables below works the moment you bind the integration. Scenario selection happens through your request data (the last 4 digits of an IBAN, a subscriber number, a scenario hint field), so it stays under your control rather than being an environment setting.

⚠️

A category you have not bound does not work — and is not silently simulated either: the request is rejected with an explicit error. This is deliberate. "It looked successful even though I had no provider connected" is exactly the state where you discover, after going live, that money never actually moved.

These integrations bind like real institutions and exercise the real code path, but they move no real money, mint no real cards and send no orders anywhere — the "Test" in their name says so.

TestBank

Last 4Behaviour
0001SUCCESS — accepted, settles
0002REJECT — bank rejection
0003TIMEOUT — dispatch times out; there is no bank reference and the state is unknown
0004LATE_APPROVE — accepted, pending for a while, then settles
0005DUPLICATE — duplicate dispatch scenario
0006HUNG — accepted but never resolves; stays pending indefinitely

IBAN ownership verification is a separate axis with its own last-4 values:

Last 4Behaviour
0007The account holder is a third party (not the user)
0008Ownership cannot be verified
anything elseThe account belongs to the user

0006 (HUNG) is not turned into AWAITING_RECON by the simulator itself — the simulator says "pending" forever, and it is the consumer-side waiting threshold that moves the operation to AWAITING_RECON. That is exactly what you should expect when testing: this scenario proves your AWAITING_RECON queue and your intervention screen work.

0007 / 0008 affect the ownership-verification path only; they are not part of the money-dispatch table.

TestATM

Last 4Behaviour
0001DISPENSE_OK — cash dispensed
0002DECLINE — ATM declined
0003TIMEOUT — confirmation times out; state unknown
0004LATE_DISPENSE — pending first, dispensed later
0005DUPLICATE — duplicate confirmation

TestBiller

Last 4Behaviour
0001SUCCESS — accepted, then confirmed
0002REJECT — biller rejection
0003TIMEOUT — times out; state unknown
0004LATE_CONFIRM — accepted, pending for a while, then confirmed
0005DUPLICATE — duplicate payment
0006HUNG — accepted, never resolves
0009No debt — the bill inquiry returns "not found"
⚠️

0009 affects the inquiry endpoint only. If you call payment directly with the same subscriber number, the simulator responds successfully. So 0009 is not a "this subscriber cannot be paid" scenario — it is a "the inquiry finds no debt" scenario.

TestIssuer

Last 4Behaviour
0002AUTH_DECLINE — authorization declined
0003ISSUE_FAIL — card minting refused
anything elseAPPROVE — minting and authorization succeed

You can select the scenario two ways — the explicit hint is recommended:

{ "providerScenarioHint": "ISSUE_FAIL" }

Valid values: APPROVE, ISSUE_FAIL, AUTH_DECLINE. When you send a hint the last-4 rule never comes into play, and the request states plainly what you are exercising.

⚠️

Without a hint, TestIssuer does not read the scenario from a card number: on minting it uses the last 4 digits found in the Idempotency-Key, and on authorization the last 4 digits of the network authorization reference. The hint field is both more readable and less brittle.

The two scenarios affect two different paths, and cross-use is a no-op: resolving AUTH_DECLINE during minting still mints the card, and resolving ISSUE_FAIL during authorization still approves it.

TestExchange

TestExchange has no scenarios. It runs on a fixed, deterministic price table with a fixed spread and zero slippage; quoting and confirmation always return the same rate, and no order is ever sent anywhere. It is suitable for exercising your rate handling and rounding — not for exercising the failure paths of an exchange integration.

Crypto endpoints (swap and crypto transfer) are disabled by default; while disabled they return WALLET_CRYPTO_DISABLED. Contact your account manager to have them enabled in your environment. When enabled, crypto operations go through the same risk, sanctions, limit and step-up checks as every other money-out operation.

Simulating an incoming bank transfer

Bank transfer top-up can be exercised end to end: generate the per-user deposit instruction, then trigger the "money arrived against that instruction" event yourself.

POST /api/v1/wallet/bank-integrations/inbound-credits

This call requires the wallet:bank-payout:operate permission on your key.

FieldTypeRequiredNotes
programIdstring✅
railIdstring✅Code of the bank integration bound to the program (e.g. TESTBANK)
amountstring✅Minor unit
currencystring✅
rawReferencestring✅Transfer description — the deposit reference is matched from this field
senderName · senderIbanstring—Sender details
creditedIbanstring—Destination account in virtual-IBAN mode
receiptNumberstring—Bank line id. Empty = generated; send the same value twice to exercise duplicate-line behaviour
{ "status": "success", "code": "SUCCESS", "data": { "receiptNumber": "SIM-3f9c…", "railId": "TESTBANK", "valueDate": "2026-08-13T00:41:13.402Z" } }

The line you inject travels the same path as the real one: the inbound scanner reads it from the statement, matches the reference against outstanding instructions, and either credits the wallet or drops it into the suspense queue. What you exercise is the code that will run in production.

Three scenarios worth running:

What you sendWhat it proves
A valid deposit reference inside rawReference plus senderNameMatch → wallet credit + wallet.topup.completed webhook
An unrecognised string inside rawReferenceThe suspense queue and your resolution screen
The right reference but no senderNameThird-party review — the money is not credited automatically
The same receiptNumber a second timeDuplicate-line de-duplication — there must be no second credit
⚠️

The third row may look surprising, but it is deliberate: if anyone who knows the reference could push money into that wallet, a user whose reference leaked could have someone else's money routed to their account. When the sender cannot be established as the account holder, the system does not credit automatically — it hands the line to an operator. Exercise this scenario too; you will meet it in production.

⚠️

This endpoint only works for programs bound to a bank integration that supports incoming-funds simulation. If you are bound to a real provider it is rejected with WALLET_INBOUND_SIMULATION_NOT_SUPPORTED — a real bank cannot be told "money arrived", which is why this capability is inert in production by construction. With no binding at all you get WALLET_BANK_RAIL_NOT_BOUND.

What the simulators cannot test

🚫

The two items below cannot be exercised end to end today. Do not build an integration plan that assumes you can.

1. Real card minting

TestIssuer does not produce a real card: it returns an opaque card token, a deterministic pseudo last-4 and a masked view. A raw PAN is never generated and never appears in any response. You can exercise your card flow's state machine and authorization declines; you cannot use a minted card physically or virtually.

2. TR Karekod interoperability

QR is closed loop today and there is no external reader. You cannot test your code by having another bank or payment app scan it. No standard compliance is claimed — details: Roadmap.

What a simulator proves, and what it does not

What a simulator exercises is your side: your state machine, your queues, your idempotency, your UI. What it does not prove is the institution's real behaviour — a real bank's decline codes, a real biller's debt format and a real ATM network's timing will not match the simulator byte for byte.

In practice: you can finish your integration against the simulators, but leave a window on day one of going live to map the real provider's error codes.

What you can do today for those flows:

  • Write your request bodies and state-machine handling against the contract.
  • Actually produce BANK_PROCESSING, AWAITING_RECON and HELD with the scenarios above and test your UI — make sure you do not render them as "failed".
  • Prepare your webhook consumer for those event types now.

Pre-launch checklist

  • Signing works on ping, on query-string endpoints and on body endpoints
  • Every money-moving call sends an Idempotency-Key derived from business meaning
  • Resending with the same key does not create a second operation (verified)
  • The webhook endpoint verifies the signature and deduplicates on x-lunixi-event-id
  • HELD and AWAITING_RECON are not rendered as "failed"
  • Users are shown totalCharge, not amount
  • Limit rejections are shown with their reason
  • A KYC threshold is configured for every outbound operation type
  • Locked capabilities are shown locked, not hidden
  • LIVE and TEST keys are separate and selected by environment variable

Common mistakes

SymptomCauseFix
TEST key does not work in productionEnvironment splitUse the key for the right environment
Signature fails only on some endpointsThe query string was not signedPut the full path in line 2 of the canonical string
The idempotency test created a second operationThe key was regeneratedKeep the key stable