Testing
Wallet integration cases to verify before production
Run these cases before enabling production traffic. Phoenix runs the reservation, capture and release, credit, money format and security cases for you with the wallet test; check the amount, deadline, partial capture, positions and launch cases by hand.
The contract lets you hold a separate reserved bucket, but it does not require one. Where a case below mentions reserved, it is observable only if your wallet keeps that bucket. Phoenix's own demo wallet does not: it collapses reserve_cash into an immediate debit, so available drops by the full amount and reserved always reports 0. Both shapes are valid; assert against available to stay portable.
Reservation Cases
| Case | Expected result |
|---|---|
Valid reserve_cash | available decreases by the reserved amount and Phoenix can open the order. reserved increases only if your wallet holds a reservation bucket (the demo wallet debits immediately and keeps reserved at 0) |
Duplicate reserve_cash idempotency key | Stored result is returned and the cash is not reserved again |
| Insufficient funds | HTTP 422 with code: "insufficient_funds" |
| Invalid signature | HTTP 401, no balance change |
| Malformed body | HTTP 400 (any 4xx), no balance change |
| Same reserve arrives again after the 60 second funding deadline | Stored result is returned. If you had taken the reserve, a release_cash with reason ORDER_REJECTED follows and the cash returns to available |
Capture and Release Cases
| Case | Expected result |
|---|---|
Partial capture_cash | Finalizes the captured cash. available does not change again (the player already lost it at reservation). If you hold a reservation bucket, reserved decreases by the captured amount; in the immediate-debit demo wallet, capture is a ledger no-op that returns the current balance |
Duplicate capture_cash idempotency key | Stored result is returned and cash is not captured again |
release_cash after cancellation | Unused cash returns to available. If you hold a reservation bucket, reserved decreases by the released amount; the demo wallet applies it as a plain credit to available |
Duplicate release_cash idempotency key | Stored result is returned and cash is not released again |
| Capture plus release exceeds reservation | HTTP 422, no balance change (checked only if your wallet keeps a reserved bucket) |
| Invalid signature | HTTP 401, no balance change |
Credit Cases
| Case | Expected result |
|---|---|
Sell proceeds credit_cash | available increases by the proceeds |
Settlement payout credit_cash | available increases by the payout |
Refund or void credit_cash | available increases by the refund |
Duplicate credit_cash idempotency key | Stored result is returned and cash is not credited again |
Retry Cases
Phoenix sends a move again, with the same body and idempotency key, after a timeout, a transport error, a 408, 429 or 5xx, or a 2xx whose status it cannot read. Your stored result makes an already-applied move a no-op.
| Case | Expected result |
|---|---|
Temporary 503, 429 or 408 | Phoenix sends the move again with the same key and it later succeeds |
| A capture, release, sell credit or rebate errors or is refused | Phoenix keeps sending it, with delays growing to five minutes; a refusal stops after 24 hours and Phoenix raises it with you |
Payout or refund credit_cash errors temporarily (5xx, timeout) | Phoenix keeps sending it, with delays growing to five minutes, and never stops on its own |
Payout or refund credit_cash is refused (4xx, for example player_not_found) | Phoenix keeps sending it for 24 hours, then stops; the payout shows as Refused in the back office until Phoenix resends it under a new key |
reserve_cash refused (422) | Final: Phoenix rejects the order and does not send it again. The player reads what your code means (see Player Protection); the reply carries details.reason and your code in details.wallet_code |
409 response | No answer, like a 503: Phoenix sends the move again under the same key. Answer an in-flight duplicate with the stored result, 409 or 503 |
reserve_cash refused as a request (401, 403) after an earlier attempt got no answer | Not final: that attempt may have reached you, so Phoenix asks again under the same key until the deadline, then rechecks |
Amount Cases
| Case | Expected result |
|---|---|
Amount for a currency with fewer decimals than the scale (for example KES, 2 decimals, at scale: 6) | The value is an exact multiple of the currency's smallest unit (here always a multiple of 10,000). Store it as sent; never round it |
Correction credit (for example reason REFUND_ROUNDING_CORRECTION) | Applied as a new credit under its own idempotency key; no earlier move is amended |
Positions Cases (wallet API 1.1)
The wallet test sends your environment's wallet API version. Under 1.1 its moves name a position on a test book of their own, and it checks nothing about final moves. If your environment uses Positions, also verify these by hand against your sandbox:
| Case | Expected result |
|---|---|
Every move carries venue_id, market_id, outcome, final in references | Stored verbatim next to the move |
Zero-amount final credit_cash (final: true, a losing or exited position) | Accepted with HTTP 200, balance unchanged, never refused |
| Two final moves for one player in one market (a player holding both sides) | Two moves, one per outcome; neither is rejected as a duplicate |
| A release or credit for a position arrives after its final move | Applied as an adjustment to the settled position, not rejected |
Status Cases (Optional)
Phoenix does not call the status route, and the wallet test does not check it. If you implement /wallet/transactions/status for your own reconciliation, a status probe is the same signed envelope as the original move (full body, same idempotency-key header). Report the recorded outcome for that key without moving money.
| Case | Expected result |
|---|---|
| Completed operation status | The recorded accepted result for that key, with its balance |
| Unknown idempotency key | A 4xx with a JSON body, never a 5xx |
Launch Cases
Test wallet behavior together with iframe launch:
- A visitor can browse but cannot place an order; tapping Buy asks your page to sign in.
- A player can place an order after the token upgrade.
- A token refresh (
token.expiring) keeps the session alive, andsession.expiredfollowed bysetTokenbrings it back. - The deposit flow opens your cashier, and the embed shows the new balance after your
balanceUpdate. - Phoenix signature verification passes in the
sandboxenvironment.
The Wallet Test
Phoenix runs the reservation, capture and release, credit, money format and security cases above against your wallet for you, and records the result. A cleared run is an item on your back office's Go-Live tab; run it against each environment, sandbox first.
Start a run with a signed Operator API request (see Request Signing). The run targets the environment you sign for, and calls that environment's saved wallet base URL:
POST /operator/api/wallet-test
Content-Type: application/json
{
"player_id": "a test player id in your wallet",
"player_id_b": "optional second test player, for the cross-player check",
"currency": "KES",
"amount": "1.00"
}| Field | Required | Notes |
|---|---|---|
player_id | Yes | A player that exists in this environment's wallet, with at least 10 times amount available |
player_id_b | No | A second player. Without it the cross-player check is not applicable |
currency | No | One of your currencies. Defaults to your first currency |
amount | No | A decimal string. Defaults to "1.00" |
The run moves real balances on the test players and puts them back at the end, so it is safe to repeat. Its requests are the ones production sends: money moves in your environment's wallet API version with reason: "wallet-tester" (under 1.0 the references are empty, under 1.1 they name a position on a test book of their own), at the scale Phoenix sends for the currency, signed with the Phoenix key, carrying the idempotency-key header and a fresh x-request-id for each attempt. Each call waits up to 15 seconds. A move that gets no answer (a timeout, 408, 409, 429 or 5xx) is sent again under the same key, up to 5 times over about 9 seconds, as production sends it.
The response lists every check with its verdict (passed, failed, skipped or not_applicable) and the request and response of each call, so a failure points at the exact bytes. The run clears when no check failed, none was skipped, and at least one passed. A not_applicable check does not count against it.
| Check | What it verifies |
|---|---|
| PF-1 | Your wallet answers, and the test player has at least 10 times the amount. If this fails, every other check is skipped |
| R-01 to R-04 | A reserve takes the amount from available; a repeated key takes it once; insufficient funds answers 422 with insufficient_funds (or a 2xx saying rejected); five identical reserves sent at once all end accepted and take the amount once. An in-flight duplicate may be answered with the stored result, 409 or 503 |
| C-01 to C-04 | Capture leaves available unchanged; release after a cancel restores it; repeated keys move nothing twice |
| K-01, K-02 | A credit adds the amount; a repeated key adds it once |
| L-01, L-02 | A won lifecycle (reserve, capture, credit) and a cancelled one (reserve, release) net out correctly |
| M-01, M-02 | Phoenix can read the balance (an integer-string value at any integer scale) and processed_at from a move's answer, and an echoed currency_code is the request's; processed_at rises with every balance change |
| X-01 | One player's moves never touch another's balance (needs player_id_b) |
| S-01 to S-07 | Calls with no signature, a bad, empty or wrong-key signature, or a changed body are refused without moving money; errors leak no internal details; the signature is checked over the raw bytes |
| F-01 | A malformed body is refused with a 4xx and moves nothing |
| Z-01 | The test player's balance is back where it started |
An in-flight duplicate answered with 409 or 503 is sent again, as production sends it, so R-04 passes when all five reserves end accepted and the amount is taken once.
Read your recent runs:
GET /operator/api/wallet-test/runsIt returns your environment's last 20 runs, newest first (ran_at, passed, passed_count, failed_count, skipped_count, failed_check_ids, the test version), the current test version, and cleared: whether your latest run cleared on the current version. The current version is 2026-10-24.1: a run on an older version no longer clears, so run the test again against each environment.
Errors before a run starts: 400 { "error": "player_id is required" } or { "error": "invalid_amount" }; 422 { "error": "currency_not_supported" }, { "error": "no_wallet_base_url" } or { "error": "wallet_url_not_public" } (the saved wallet base URL fails the public https rule); and 422 forbidden for a body field the endpoint does not take (see Operator API).