Testing
- 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/pingIt 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=1This 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/lookupThis 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:
| Scenario | What it proves |
|---|---|
| Create user → create again with the same phone | Phone deduplication |
| Open an account → read the balance | Account/currency model |
| Start card top-up → open the form → complete | Top-up flow and webhook |
Resend the top-up with the same Idempotency-Key | Idempotency behaviour |
| W2W quote → init → complete | Transfer flow, OTP, integrityHash |
Call complete with a wrong nonce | The verification gate |
| Transfer more than the balance | sufficientBalance and rejection behaviour |
| Transfer above the limit | limitBlocked behaviour |
| Wallet payment → customer approval | Payment flow |
| Generate QR → parse → pay | QR flow |
| Verify a webhook signature | Your 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:
| Simulator | What it stands in for | Scenario selector |
|---|---|---|
| TestBank | The bank integration (withdrawals, IBAN transfers, IBAN bulk payouts, IBAN ownership checks) | Last 4 digits of the destination IBAN |
| TestATM | The ATM / QR cash-out network | Last 4 characters of the cash-out code id |
| TestBiller | A biller (inquiry + payment) | Last 4 digits of the subscriber number |
| TestIssuer | The card issuer (minting + authorization) | See the note below — not the card number |
| TestExchange | Crypto swap rates | No 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 card | Category | What it opens |
|---|---|---|
| Test Bank | Bank integration | Bank transfer top-up, bank withdrawal, IBAN transfer, IBAN-targeted bulk payouts |
| Test ATM Network | ATM network | ATM and QR cash-out |
| Test Biller | Biller | Bill inquiry and payment |
| Test Card Issuer | Card issuer | Card minting, authorization, settle, freeze/cancel |
| Test Exchange | Crypto | Fiat↔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 4 | Behaviour |
|---|---|
0001 | SUCCESS — accepted, settles |
0002 | REJECT — bank rejection |
0003 | TIMEOUT — dispatch times out; there is no bank reference and the state is unknown |
0004 | LATE_APPROVE — accepted, pending for a while, then settles |
0005 | DUPLICATE — duplicate dispatch scenario |
0006 | HUNG — accepted but never resolves; stays pending indefinitely |
IBAN ownership verification is a separate axis with its own last-4 values:
| Last 4 | Behaviour |
|---|---|
0007 | The account holder is a third party (not the user) |
0008 | Ownership cannot be verified |
| anything else | The 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 4 | Behaviour |
|---|---|
0001 | DISPENSE_OK — cash dispensed |
0002 | DECLINE — ATM declined |
0003 | TIMEOUT — confirmation times out; state unknown |
0004 | LATE_DISPENSE — pending first, dispensed later |
0005 | DUPLICATE — duplicate confirmation |
TestBiller
| Last 4 | Behaviour |
|---|---|
0001 | SUCCESS — accepted, then confirmed |
0002 | REJECT — biller rejection |
0003 | TIMEOUT — times out; state unknown |
0004 | LATE_CONFIRM — accepted, pending for a while, then confirmed |
0005 | DUPLICATE — duplicate payment |
0006 | HUNG — accepted, never resolves |
0009 | No 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 4 | Behaviour |
|---|---|
0002 | AUTH_DECLINE — authorization declined |
0003 | ISSUE_FAIL — card minting refused |
| anything else | APPROVE — 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-creditsThis call requires the wallet:bank-payout:operate permission on your key.
| Field | Type | Required | Notes |
|---|---|---|---|
programId | string | ✅ | |
railId | string | ✅ | Code of the bank integration bound to the program (e.g. TESTBANK) |
amount | string | ✅ | Minor unit |
currency | string | ✅ | |
rawReference | string | ✅ | Transfer description — the deposit reference is matched from this field |
senderName · senderIban | string | — | Sender details |
creditedIban | string | — | Destination account in virtual-IBAN mode |
receiptNumber | string | — | 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 send | What it proves |
|---|---|
A valid deposit reference inside rawReference plus senderName | Match → wallet credit + wallet.topup.completed webhook |
An unrecognised string inside rawReference | The suspense queue and your resolution screen |
The right reference but no senderName | Third-party review — the money is not credited automatically |
The same receiptNumber a second time | Duplicate-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_RECONandHELDwith 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-Keyderived 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 -
HELDandAWAITING_RECONare not rendered as "failed" - Users are shown
totalCharge, notamount - 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
| Symptom | Cause | Fix |
|---|---|---|
| TEST key does not work in production | Environment split | Use the key for the right environment |
| Signature fails only on some endpoints | The query string was not signed | Put the full path in line 2 of the canonical string |
| The idempotency test created a second operation | The key was regenerated | Keep the key stable |