Phoenix Prediction Docs

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

CaseExpected result
Valid reserve_cashavailable 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 keyStored result is returned and the cash is not reserved again
Insufficient fundsHTTP 422 with code: "insufficient_funds"
Invalid signatureHTTP 401, no balance change
Malformed bodyHTTP 400 (any 4xx), no balance change
Same reserve arrives again after the 60 second funding deadlineStored 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

CaseExpected result
Partial capture_cashFinalizes 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 keyStored result is returned and cash is not captured again
release_cash after cancellationUnused 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 keyStored result is returned and cash is not released again
Capture plus release exceeds reservationHTTP 422, no balance change (checked only if your wallet keeps a reserved bucket)
Invalid signatureHTTP 401, no balance change

Credit Cases

CaseExpected result
Sell proceeds credit_cashavailable increases by the proceeds
Settlement payout credit_cashavailable increases by the payout
Refund or void credit_cashavailable increases by the refund
Duplicate credit_cash idempotency keyStored 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.

CaseExpected result
Temporary 503, 429 or 408Phoenix sends the move again with the same key and it later succeeds
A capture, release, sell credit or rebate errors or is refusedPhoenix 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 responseNo 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 answerNot final: that attempt may have reached you, so Phoenix asks again under the same key until the deadline, then rechecks

Amount Cases

CaseExpected 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:

CaseExpected result
Every move carries venue_id, market_id, outcome, final in referencesStored 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 moveApplied 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.

CaseExpected result
Completed operation statusThe recorded accepted result for that key, with its balance
Unknown idempotency keyA 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, and session.expired followed by setToken brings it back.
  • The deposit flow opens your cashier, and the embed shows the new balance after your balanceUpdate.
  • Phoenix signature verification passes in the sandbox environment.

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"
}
FieldRequiredNotes
player_idYesA player that exists in this environment's wallet, with at least 10 times amount available
player_id_bNoA second player. Without it the cross-player check is not applicable
currencyNoOne of your currencies. Defaults to your first currency
amountNoA 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.

CheckWhat it verifies
PF-1Your 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-04A 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-04Capture leaves available unchanged; release after a cancel restores it; repeated keys move nothing twice
K-01, K-02A credit adds the amount; a repeated key adds it once
L-01, L-02A won lifecycle (reserve, capture, credit) and a cancelled one (reserve, release) net out correctly
M-01, M-02Phoenix 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-01One player's moves never touch another's balance (needs player_id_b)
S-01 to S-07Calls 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-01A malformed body is refused with a 4xx and moves nothing
Z-01The 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/runs

It 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).