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_statusnone (echoes the original key in the body)

{wallet_base_url} is the base URL configured for the operator environment. Every request - money-moving or not - is Ed25519-signed (see Request Signing) and carries a per-attempt X-Request-Id header for tracing.

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, expires, or fills below its maximum costORDER_CANCELLED, ORDER_TERMINATED, PRICE_IMPROVEMENT
credit_cashFor sell proceeds, maker rebates, and settlement payoutsORDER_FILLED, MAKER_REBATE, MARKET_SETTLED
balanceTo seed the display on connect, and during reconciliationn/a (no money move)
transaction_statusAfter an uncertain timeout, to learn a move's outcomeechoes the original move's reason

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 currently emits scale: 6 for every amount; always read the scale field rather than assuming a fixed value, so you stay correct if a currency is later configured at a different scale.

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_versionstringyesAlways "1.0".
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.

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_versionstringAlways "1.0".
statusstring"accepted". A business refusal is not an accepted response with a different status - it is an HTTP 422; see Errors.
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, and on a 422 rejection. The player's cash in the request currency_code, with available (spendable) and reserved (locked) amounts. Phoenix surfaces available to the player. 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 number 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

A business rejection is terminal: respond with HTTP 422 and an application/problem+json body carrying a code extension member. Phoenix classifies the rejection by code and never retries it. Include the current balance so Phoenix can reconcile.

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

code is a free-form string you choose; Phoenix records it and surfaces it for reconciliation. Common values: insufficient_funds, player_not_found. Use the status codes below - Phoenix keys its retry-vs-terminal decision on the HTTP status:

StatusUse whenPhoenix treats as
400Payload or required header is malformedterminal
401 / 403Signature verification failed or the key is not allowedterminal
409The same idempotency key is still processingretry
422Business rejection, idempotency-fingerprint mismatch, unknown reservation, or amount exceeds remaining reservationterminal
429Wallet is rate-limiting Phoenixretry
503Temporary operator wallet outageretry

Only the 422 business rejection needs the problem+json code shape. For the others Phoenix keys purely on the HTTP status, so a plain JSON body (for example { "error": "unauthorized" }) is sufficient. Transport errors and timeouts are retried with the same idempotency key.

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": "360834054527976040",
  "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 }
  }
}

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": "360834054527976040",
  "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 }
  }
}

Reject with 422 code: "amount_exceeds_reservation" if the capture would exceed the remaining reservation.

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 (cancel), ORDER_TERMINATED (expiry / budget remainder), 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": "360834054527976040",
  "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 }
  }
}

Reject with 422 code: "amount_exceeds_reservation" if the release would exceed what remains reserved.

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 (or capture_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" }. Sell proceeds / maker rebate on a matched trade: { "taker_order_id": "<uuid>", "maker_order_id": "<uuid>" }.

Request - settlement payout:

{
  "api_version": "1.0",
  "operation": "credit_cash",
  "idempotency_key": "01J8ZG14H70AE2F6A9C7A0DC8E91",
  "operator_id": "360834054527976040",
  "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 transport/availability errors (429, 503, timeouts) so Phoenix retries.

balance

POST /wallet/balance

Read a player's current balance without moving money - on first connect (to seed the embed's balance display) and during reconciliation. 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": "360834054527976040",
  "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 - once on connect to seed the display, then during reconciliation - because every money-move response already carries the up-to-date balance. A player_not_found returns the 422 error shape.

transaction_status

POST /wallet/transactions/status

Learn the outcome of an earlier money move after an uncertain timeout, without re-executing it. The body is the same envelope as the original money move (same operation, idempotency_key, amount, reason, references) - just sent to the status path. Look the operation up by its idempotency key and report its current state; do not move money.

Request - probing the reserve_cash above:

{
  "api_version": "1.0",
  "operation": "reserve_cash",
  "idempotency_key": "01J8ZF8E6C2A7B70AE2F6A9C7A0D7F71",
  "operator_id": "360834054527976040",
  "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 - 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 operation reached a terminal business rejection, return that same rejection (the 422 error shape). If you have no record of the key, the move never reached you - return the 422 error shape so the probe stays readable. Phoenix never auto-resolves from a probe: it compares your reported status and balance against its own expectation and, on any disagreement or unreadable response, flags the move for a human to resolve from a runbook - it never re-moves or rewrites money. The status endpoint must never move money; it reports the stored outcome for an idempotency key and nothing else. See Reconciliation for the full probe-and-resolve flow.