Phoenix Prediction Docs

Wallet Operations

Implement reserve, release, capture, credit, balance, and status operations

Phoenix calls the operator wallet when prediction-market cash has to be reserved, released, captured, credited, checked, or reconciled.

The operator wallet remains the source of truth for external player cash. Phoenix owns the trading subledger: orders, fills, positions, internal ledger entries, and settlement decisions.

Endpoints

There are three routes. Every operation is one POST with a JSON body whose operation field names the action.

RouteOperationsIdempotency-Key header
POST {wallet_base_url}/wallet/transactionsreserve_cash, capture_cash, release_cash, credit_cashrequired
POST {wallet_base_url}/wallet/balancebalancenone
POST {wallet_base_url}/wallet/transactions/statustransaction_status (optional: Phoenix does not call it)the original move's key

{wallet_base_url} is the base URL configured for the operator environment. Every request, money-moving or not, is signed by Phoenix (see Security) and carries a per-attempt x-request-id header for tracing. Phoenix waits up to 15 seconds for an answer.

OperationWhen Phoenix calls itTypical reason
reserve_cashBefore a buy order becomes open on the bookORDER_REQUESTED
capture_cashWhen a buy-side fill consumes reserved cashORDER_FILLED
release_cashWhen an order is cancelled, ends with money unspent, is refused after its reserve, or fills below its maximum costORDER_CANCELLED, ORDER_TERMINATED, ORDER_REJECTED, PRICE_IMPROVEMENT
credit_cashFor sell proceeds, maker rebates, and settlement payoutsORDER_FILLED, MAKER_REBATE, MARKET_SETTLED
balanceTo show the player's balance when the embed connectsn/a (no money move)
transaction_statusNever. Phoenix does not call it; implement it only for your own reconciliationechoes the original move's reason

Wallet base URL

In production the wallet base URL must be a public https URL. Phoenix refuses to save, and refuses to call, a base URL that:

  • is not https;
  • has localhost as its host, or a host that is a single label (no dot), or that ends in .local, .localhost, .internal, .svc, .cluster.local, .lan or .home.arpa;
  • is an IP address in a private, loopback, link-local or otherwise reserved range.

When the environment is saved, the host name must also resolve, and only to public addresses. The rule is checked again before every wallet call: a move to a URL that fails it is recorded as not sent, with the code wallet_url_not_public, and no request is made. Use a public hostname even for sandbox; see Environments for ways to reach a development wallet.

Phoenix never follows a redirect. A signed move goes only to the base URL saved on the environment, so a 3xx answer is read as your wallet refusing the request (reason http_307, for example). Save the final URL, not one that redirects.

Covered sell orders do not reserve cash with the operator wallet - Phoenix locks the seller's claim quantity inside its own trading subledger. The reason is a free-form label recording why cash moved; treat it as informational and classify each request by operation, never by reason.

Money format

Wallet cash amounts are an integer string in the currency's smallest unit plus a scale, repeating the currency_code inside the amount object.

{
  "value": "12500000",
  "scale": 6,
  "currency_code": "USDT"
}

This represents 12.500000 USDT. Never use floating-point for wallet movement. Phoenix sends scale: 6, or the currency's own number of decimals when that is more (BTC and ETH use 8, XLM uses 7). Always read the scale field rather than assuming a fixed value.

Exact amounts and rounding

Every amount Phoenix sends is an exact multiple of the currency's smallest unit: 2 decimals for KES and USD, 6 for USDT, 8 for BTC. Phoenix decides each amount once, at that precision, and sends it unchanged, so what your wallet moves is what Phoenix recorded. Nothing is truncated on the wire. For a 2-decimal currency at scale: 6 the value is always a multiple of 10,000: 12.50 KES is "12500000", and a KES move never carries a fraction of a cent.

Where a calculation produces more decimals than the currency allows, Phoenix rounds once, in the house's favour:

AmountRounded
Price per share of an AMM buyUp
Price per share of an AMM sellDown
Taker feeUp
Maker rebateDown
Settlement payout and void refundDown

Two consequences for your wallet:

  • A buy's reservation is held to the smallest unit too. It can be more than the order finally costs, because the fee provision per share is rounded up to the 0.01 price step. Phoenix returns the surplus with a release_cash when the order fills or ends, so every reservation closes to zero: captured, released, or both.
  • Phoenix never rewrites a move it already sent. A correction is a separate credit_cash with its own reason, idempotency key and references, for example reason REFUND_ROUNDING_CORRECTION. Apply it as a new move; do not look for an earlier one to amend.

Order sizes sit on a matching grid, so players cannot place an order that would need a finer amount. See Order size precision.

The money-move request

reserve_cash, capture_cash, release_cash, and credit_cash all POST /wallet/transactions with the same envelope. Only operation, reason, amount, and references change between them - the per-operation sections below give each concrete shape.

FieldTypeRequiredDescription
api_versionstringyes"1.0", or "1.1" for a market book opened after its environment opted in to Positions.
operationstringyesreserve_cash | capture_cash | release_cash | credit_cash.
idempotency_keystringyesOpaque, stable, platform-generated. Key your dedup on operator_id + environment + operation + idempotency_key; do not parse it. See Idempotency.
operator_idstringyesYour operator id.
environmentstringyessandbox | prod.
player.external_idstringyesThe operator-side player id. Phoenix never sends its internal player UUID across the wallet boundary.
currency_codestringyesThe currency the move is denominated in (the session currency Phoenix pinned).
amountmoneyyesThe amount to move.
reasonstringyesInformational label (per-operation values above).
referencesobjectyesCorrelation hints for your own reconciliation; fields vary by operation. The authoritative dedup identity is idempotency_key, not anything in references. Under 1.1 it also names the move's position (see Positions).

The body is byte-identical across retries of the same idempotency_key, so your request-fingerprint check always matches on a retry. Per-attempt tracing rides in the X-Request-Id header, never in the body.

The success response

Every successful operation returns HTTP 200 or 201 with this shape. Money moves and transaction_status include idempotency_key and one reference id; a balance read omits both.

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "reserve_cash",
  "idempotency_key": "01J8ZF8E6C2A7B70AE2F6A9C7A0D7F71",
  "processed_at": 1779280245300,
  "operator_reservation_id": "op_res_456",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "875000000", "scale": 6 },
    "reserved": { "value": "0", "scale": 6 }
  }
}
FieldTypeDescription
api_versionstringEcho the request's api_version. Phoenix does not read it.
statusstring"accepted". Answer a business refusal with HTTP 422 (see Errors). A 2xx whose status is "rejected" is also read as a refusal, and a 2xx with any other status is treated as unknown and sent again.
operationstringEchoes the request operation.
idempotency_keystringEchoes the request key. Present on money moves and transaction_status; absent on balance.
processed_atnumberThe balance version, epoch milliseconds. Required - see below.
reference idstringoperator_reservation_id on reserve_cash, operator_wallet_transaction_id on every other money move. Return exactly one, never both. Present on money moves and transaction_status; absent on balance.
balanceobjectThe player's cash after the operation - see below.

Phoenix reads the balance, not the reference id

Phoenix consumes status, balance, and processed_at. The reference id is for your own records: Phoenix does not echo it back on later capture_cash or release_cash calls - it correlates an order's lifecycle through its own idempotency keys and the order_id in references. Phoenix also does not read your currency_code echo: it owns the denomination (the session currency it sent on the request).

The balance object

Returned on every success. The player's cash in the request currency_code, with available (spendable) and reserved (locked) amounts. Phoenix shows available to the player. (You may include it on a refusal too; Phoenix does not read it there.) Duplicate completed requests (same idempotency key, same request fingerprint) must return their original balance unchanged.

{
  "currency_code": "USDT",
  "available": { "value": "875000000", "scale": 6 },
  "reserved": { "value": "12500000", "scale": 6 }
}

processed_at

processed_at is the balance version, and is required on every success response. It is a numeric timestamp (epoch milliseconds) marking the instant the balance in this response became authoritative, and it MUST be strictly increasing per player across every balance change: a money move and a standalone balance read of the same value carry the same processed_at; the next change carries a higher one. Phoenix shows the player's balance live and applies updates highest-version-wins, so two signals - a money-move response here, and a balance you push into the embed when it changes elsewhere on your platform (a deposit, a withdrawal, a bet on another product; see the host SDK) - merge without an older value ever overwriting a newer one. If two changes can land in the same millisecond, bump the value (max(now_ms, last + 1)) so it never ties.

Return processed_at as a JSON integer under exactly that name. Phoenix orders balances by it, so a response it cannot read a numeric processed_at from has no placeable version: the balance is dropped and the player sees nothing - even when every other field is correct. An ISO-8601 timestamp string, or the value under another name (retrieved_at, as_of, ...), counts as absent.

Errors

Declining a reservation

reserve_cash is the only call your wallet should decline. Answer a business refusal with HTTP 422 and an application/problem+json body carrying a code member:

{
  "type": "about:blank",
  "title": "wallet operation rejected",
  "status": 422,
  "code": "insufficient_funds",
  "operation": "reserve_cash"
}

code is a string you choose. Phoenix records it as the reason and shows it:

  • In the back office, on the rejected order (your code; your detail, message or title text is kept for refused payouts).
  • To the player, as what the code means: not enough funds, a limit, betting paused, verification needed, or an account that can't bet now, each in the player's language and with the page of yours it needs (see Player Protection). A code with no meaning reads "Acme declined this bet. Nothing was taken.", with your display name. Your code travels in reason.details.wallet_code of the command reply (see Errors).

These codes have a meaning without any setting; use them where they fit:

CodeUse it whenThe player reads
insufficient_fundsThe player does not have the moneyYou don't have enough funds for this bet.
limit_exceededThe bet would break a deposit, loss or stake limitThis bet would go over a limit on your Acme account.
self_excludedThe player has excluded themselvesBetting is paused on your Acme account.
cooling_offThe player is on a time-outBetting is paused on your Acme account.
verification_requiredThe player must verify their account firstAcme needs to verify your account before you can bet.
account_blockedThe account may not betYour Acme account can't place bets right now. Contact Acme for help.
player_not_foundYou do not know the playerYour Acme account can't place bets right now. Contact Acme for help.

If your wallet already sends other codes, give each a meaning in the back office under Manage > Your operator > Player protection instead of changing your wallet. Choose codes you are comfortable seeing in support tools, and keep player data out of them.

How Phoenix reads an answer

AnswerPhoenix reads it as
2xx with "status": "accepted"Success
2xx with "status": "rejected"The wallet's decision to refuse the move; the reason is the body's code, else its reason
2xx with no recognised statusUnknown: sent again with the same idempotency key
422The wallet's decision to refuse the move; the reason is the body's code, else its error, else http_422
408, 409, 429, any 5xxNo answer: sent again with the same idempotency key
Timeout, connection refused or resetNo answer: sent again with the same idempotency key
Any other 4xx (400, 401, 403, ...)A refusal of the request: the reason is the body's code, else its error, else http_<status> (for example http_401)
3xxA refusal of the request, with the reason http_<status>. Phoenix never follows a redirect
Phoenix does not send the moveNot sent: Phoenix's own rule refused it before any request (see Refusals Phoenix records itself)

Phoenix limits how many requests it has open to your wallet at once, separately for each environment of each operator, so a slow sandbox wallet never holds up production, even on the same host. It lowers that number while your wallet answers 429 or leaves requests unanswered, and raises it again once answers come back. Answer 429 when you need Phoenix to slow down. New buys that would only wait on a wallet that cannot keep up are refused with rate_limited (see Errors) instead of queuing for seconds.

What an answer does depends on the move:

MoveAfter the wallet's decision to refuse it, or a refusal of the request
reserve_cashThe buy order is rejected and the player told why. Phoenix does not send it again, with one exception: if an earlier attempt of the same reserve got no answer, it may have reached you, so Phoenix asks again under the same key until its decision is known (see Funding deadline)
Payout or refund (credit_cash, reason MARKET_SETTLED)Sent again with growing delays, up to five minutes apart. Once your wallet has refused it for 24 hours, it stops and shows as Refused in the back office (see credit_cash)
Every other move (capture_cash, release_cash, sell and merge proceeds, maker rebates, corrections)Sent again with growing delays, up to five minutes apart. These moves record trades that already happened, so they cannot be declined: once your wallet has refused one for 24 hours, it stops and Phoenix raises it with you

The 24 hours count from your wallet's first refusal of the move. Temporary failures never stop the retries for any move: the delay grows to a ceiling of five minutes and Phoenix keeps trying. A move that got no answer may have reached you, so it is sent again only under the same key, and it never stops on its own.

Answer an in-flight duplicate with the stored result, 409 or 503

If the same idempotency key is still being processed, return the stored result once it is ready (see Idempotency), or answer 409 or 503. Phoenix sends the move again under the same key either way, and the wallet test accepts all three.

A signature you cannot verify (401) is a refusal of the request: on any move except a first reservation it is sent again with growing delays, and it stops after 24 hours of refusals. A 401 on the first attempt at a reservation rejects the order. After an attempt whose answer was lost, a 401 does not end the reservation: Phoenix asks again until the deadline, then rechecks (see Funding deadline). Fix the cause quickly.

Refusals Phoenix records itself

Some refusals are decided by Phoenix before any request reaches your wallet. Phoenix does not send the move, and records it as not sent with one of these codes: wallet_url_not_public (the base URL is not a public https URL), environment_not_active (the environment is suspended or archived, or has no wallet URL), environment_not_found, unknown_account, missing_currency, missing_operator, missing_environment and missing_outcome. Your wallet is never called, so these are not your wallet's refusals:

  • The player is told "this bet could not be sent to your wallet; nothing was taken", with the code in reason.details.phoenix_code of the command reply (see Errors).
  • The order is rejected with the reason wallet_not_called:<code>, and the back office reads it as not sent, with Phoenix's code.
  • A payout, or any other move, Phoenix did not send is not your wallet's refusal and never stops on its own. It stays owed and is sent again.

Sending a refused move again

Once a move stops after 24 hours of refusals, a Phoenix person resolves the cause with you, then either sends it again or records that you settled it outside the platform. A resend uses a new idempotency key for each move your wallet refused, since your wallet may store a refusal and answer the same key with it again. The new key asks for the same move: the same operation, player, amount, reason and references, so match it to the original by its references, not by its key. Phoenix never sends the refused key again, and a move your wallet accepted is never sent again. A move that may have reached you without an answer keeps its key on a resend, so it can never move money twice. If your wallet refuses the resend, it is sent again with growing delays for another 24 hours.

reserve_cash

POST /wallet/transactions

Called before a buy order becomes open. Lock the cash out of available. Reject if available cash is insufficient.

FieldValue
reasonORDER_REQUESTED
amountThe cash to lock.
references{ "order_id": "<uuid>" } - the order being reserved for.

Request:

{
  "api_version": "1.0",
  "operation": "reserve_cash",
  "idempotency_key": "01J8ZF8E6C2A7B70AE2F6A9C7A0D7F71",
  "operator_id": "7c1e2a64-3f5b-4d2a-9b8e-2f6a1c9d4e10",
  "environment": "sandbox",
  "player": { "external_id": "operator-player-123" },
  "currency_code": "USDT",
  "amount": { "value": "12500000", "scale": 6, "currency_code": "USDT" },
  "reason": "ORDER_REQUESTED",
  "references": { "order_id": "018f4f8e-6c2a-7b70-ae2f-6a9c7a0d7f71" }
}

Success - available drops by the amount, reserved rises by it (success shape):

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "reserve_cash",
  "idempotency_key": "01J8ZF8E6C2A7B70AE2F6A9C7A0D7F71",
  "processed_at": 1779280245300,
  "operator_reservation_id": "op_res_456",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "875000000", "scale": 6 },
    "reserved": { "value": "12500000", "scale": 6 }
  }
}

Rejection - insufficient cash, HTTP 422 (error shape):

{
  "type": "about:blank",
  "title": "wallet operation rejected",
  "status": 422,
  "code": "insufficient_funds",
  "operation": "reserve_cash",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "10000000", "scale": 6 },
    "reserved": { "value": "0", "scale": 6 }
  }
}

Funding deadline

A buy must be funded within 60 seconds of the player's request. Within that window Phoenix sends the reserve again, under the same idempotency key, when it got no answer (a timeout, 408, 409, 429, 5xx), and when a request is refused after an earlier attempt whose answer was lost, since that attempt may have reached you.

At 60 seconds the order is refused at once, with the reason expired, whatever your wallet does next. The player sees "the wallet did not confirm the bet in time; any money held for it is returned". Phoenix then asks what you hold, as a separate question: it sends the same reserve_cash request with the same idempotency key again, and keeps sending it until your wallet decides it.

  • If you took the reserve, or take it now, Phoenix releases it with a release_cash (reason ORDER_REJECTED), so nothing stays held.
  • If you decline it, Phoenix treats nothing as held.

Answer the repeat the way you answer any duplicate: with the stored result.

capture_cash

POST /wallet/transactions

Called after a buy-side fill consumes reserved cash. Captures can be partial. The player already lost available at reservation time, so capture moves cash out of reserved into final spent cash - available does not change again.

FieldValue
reasonORDER_FILLED
amountThe reserved cash now spent (≤ the remaining reservation).
references{ "order_id": "<uuid>" } - the filled order.

Request:

{
  "api_version": "1.0",
  "operation": "capture_cash",
  "idempotency_key": "01J8ZF9Q2C7B70AE2F6A9C7A0D8A11",
  "operator_id": "7c1e2a64-3f5b-4d2a-9b8e-2f6a1c9d4e10",
  "environment": "sandbox",
  "player": { "external_id": "operator-player-123" },
  "currency_code": "USDT",
  "amount": { "value": "12500000", "scale": 6, "currency_code": "USDT" },
  "reason": "ORDER_FILLED",
  "references": { "order_id": "018f4f8e-6c2a-7b70-ae2f-6a9c7a0d7f71" }
}

Success - the 12.5 reservation is fully captured, so reserved returns to zero and available is unchanged:

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "capture_cash",
  "idempotency_key": "01J8ZF9Q2C7B70AE2F6A9C7A0D8A11",
  "processed_at": 1779280262110,
  "operator_wallet_transaction_id": "op_wtx_789",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "875000000", "scale": 6 },
    "reserved": { "value": "0", "scale": 6 }
  }
}

A capture larger than what remains reserved means the two ledgers disagree. You may answer 422 with code: "amount_exceeds_reservation", but Phoenix keeps sending the capture for 24 hours and then raises it with you, so raise it with Phoenix at once.

release_cash

POST /wallet/transactions

Called to return reserved cash the order will not spend. Release returns cash to available and decreases reserved. Cumulative released plus captured must never exceed the original reservation.

FieldValue
reasonORDER_CANCELLED (a cancel, including resting orders cancelled when the market settles), ORDER_TERMINATED (the unfilled rest of a market order or of a limit order on an AMM, a budget buy's unspent money, or an order that could not be admitted), ORDER_REJECTED (the order was refused after you funded it, including past the funding deadline), or PRICE_IMPROVEMENT (per-fill over-hold when a buy fills below its reserved cost)
amountThe reserved cash to return.
references{ "order_id": "<uuid>" }.

Request - full cancel of the reservation above:

{
  "api_version": "1.0",
  "operation": "release_cash",
  "idempotency_key": "01J8ZFB7G70AE2F6A9C7A0DA4C2D",
  "operator_id": "7c1e2a64-3f5b-4d2a-9b8e-2f6a1c9d4e10",
  "environment": "sandbox",
  "player": { "external_id": "operator-player-123" },
  "currency_code": "USDT",
  "amount": { "value": "12500000", "scale": 6, "currency_code": "USDT" },
  "reason": "ORDER_CANCELLED",
  "references": { "order_id": "018f4f8e-6c2a-7b70-ae2f-6a9c7a0d7f71" }
}

Success - the 12.5 returns to available:

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "release_cash",
  "idempotency_key": "01J8ZFB7G70AE2F6A9C7A0DA4C2D",
  "processed_at": 1779280300005,
  "operator_wallet_transaction_id": "op_wtx_790",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "887500000", "scale": 6 },
    "reserved": { "value": "0", "scale": 6 }
  }
}

A release larger than what remains reserved means the two ledgers disagree. As with a capture, a 422 is sent again for 24 hours, so raise it with Phoenix.

credit_cash

POST /wallet/transactions

Called to increase available cash: sell-side fill proceeds, maker rebates, and settlement payouts. Never use negative credits: a correction is a new compensating credit_cash with its own reason and idempotency key.

FieldValue
reasonORDER_FILLED (sell proceeds), MAKER_REBATE, or MARKET_SETTLED (settlement payout)
amountThe cash to add to available.
referencesSettlement payout: { "claim_side": "a" } ("a" or "b"). Sell proceeds, merge credit, or maker rebate on a matched trade: { "taker_order_id": "<uuid>", "maker_order_id": "<uuid>" }. Maker rebate on a mint: { "order_id": "<uuid>" }. Correction (reason REFUND_ROUNDING_CORRECTION): { "payout_id": "<uuid>", "claim_side": "a" }.

Request - settlement payout:

{
  "api_version": "1.0",
  "operation": "credit_cash",
  "idempotency_key": "01J8ZG14H70AE2F6A9C7A0DC8E91",
  "operator_id": "7c1e2a64-3f5b-4d2a-9b8e-2f6a1c9d4e10",
  "environment": "sandbox",
  "player": { "external_id": "operator-player-123" },
  "currency_code": "USDT",
  "amount": { "value": "20000000", "scale": 6, "currency_code": "USDT" },
  "reason": "MARKET_SETTLED",
  "references": { "claim_side": "a" }
}

Success - 20.000000 credited on top of the 875.000000 available:

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "credit_cash",
  "idempotency_key": "01J8ZG14H70AE2F6A9C7A0DC8E91",
  "processed_at": 1779282318640,
  "operator_wallet_transaction_id": "op_wtx_815",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "895000000", "scale": 6 },
    "reserved": { "value": "0", "scale": 6 }
  }
}

A credit has no business rejection in normal operation; surface only temporary errors (429, 503, timeouts) so Phoenix sends it again.

If you do refuse a settlement payout or refund (a 4xx, for example player_not_found for a closed account), Phoenix sends it again with growing delays, up to five minutes apart, and stops once your wallet has refused it for 24 hours. The payout then shows as Refused in the back office (on Trades, Finance and the player's page), with your code as the reason, and that market's settlement for your operator stays incomplete. Fix the cause, then ask Phoenix to resend it: the resend uses a new idempotency key (see Sending a refused move again). Temporary failures (5xx, timeouts) never stop the retries: they back off to a ceiling of five minutes between attempts and continue.

Never refuse a zero-amount final credit (see Positions). It moves no money, but a refused one becomes a Refused payout and blocks that market's settlement from completing.

balance

POST /wallet/balance

Read a player's current balance without moving money, to show it in the embed when it connects. Carries no amount and no idempotency key.

FieldTypeRequiredDescription
api_versionstringyesAlways "1.0".
operationstringyes"balance".
operator_idstringyesYour operator id.
environmentstringyessandbox | prod.
player.external_idstringyesThe operator-side player id.
currency_codestringyesThe currency to report the balance in.

Request:

{
  "api_version": "1.0",
  "operation": "balance",
  "operator_id": "7c1e2a64-3f5b-4d2a-9b8e-2f6a1c9d4e10",
  "environment": "sandbox",
  "player": { "external_id": "operator-player-123" },
  "currency_code": "ETB"
}

Success - the player's current balance in the requested currency_code. This is the success shape without idempotency_key and without a reference id (no money moved):

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "balance",
  "processed_at": 1779280245300,
  "balance": {
    "currency_code": "ETB",
    "available": { "value": "875000000", "scale": 6 },
    "reserved": { "value": "12500000", "scale": 6 }
  }
}

Return available only in the requested currency_code, never another currency. The one nuance versus a money move is processed_at: a read does not mint a new version, it echoes the version of the balance it is reporting - the value a money move stamped when this balance was last set, not the time you handled the read. So a balance read and a money move that crossed it on the wire order correctly (highest processed_at wins) instead of a stale read clobbering a fresh move. It is required here just as on a money move: this is the read that seeds the portfolio on connect, so a missing or non-numeric processed_at leaves the player with no balance shown.

Phoenix calls this route rarely, mainly when the embed connects, because every money-move response already carries the up-to-date balance. Answer an unknown player with a 422 and code: "player_not_found".

transaction_status

POST /wallet/transactions/status

Report the stored outcome of an earlier money move without re-executing it. The body is the same envelope as the original money move (same operation, idempotency_key, amount, reason, references), with the same idempotency-key header, sent to the status path. Look the move up by its idempotency key and report it; do not move money.

Phoenix does not call this route: after a timeout it sends the original move again under the same key, and your stored result answers it. The wallet test does not check it either. Implement it only if it helps your own reconciliation; the rest of this section is the shape to use.

Request, probing the reserve_cash above:

{
  "api_version": "1.0",
  "operation": "reserve_cash",
  "idempotency_key": "01J8ZF8E6C2A7B70AE2F6A9C7A0D7F71",
  "operator_id": "7c1e2a64-3f5b-4d2a-9b8e-2f6a1c9d4e10",
  "environment": "sandbox",
  "player": { "external_id": "operator-player-123" },
  "currency_code": "USDT",
  "amount": { "value": "12500000", "scale": 6, "currency_code": "USDT" },
  "reason": "ORDER_REQUESTED",
  "references": { "order_id": "018f4f8e-6c2a-7b70-ae2f-6a9c7a0d7f71" }
}

The move completed: return its stored result unchanged (the same status, reference id, and balance the original returned):

{
  "api_version": "1.0",
  "status": "accepted",
  "operation": "reserve_cash",
  "idempotency_key": "01J8ZF8E6C2A7B70AE2F6A9C7A0D7F71",
  "processed_at": 1779280245300,
  "operator_reservation_id": "op_res_456",
  "balance": {
    "currency_code": "USDT",
    "available": { "value": "875000000", "scale": 6 },
    "reserved": { "value": "12500000", "scale": 6 }
  }
}

If the original move was refused, return that same refusal. If you have no record of the key, the move never reached you: answer with a 4xx and a JSON body (for example the 422 error shape with code: "not_found"), never a 5xx.