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.
| Route | Operations | Idempotency-Key header |
|---|---|---|
POST {wallet_base_url}/wallet/transactions | reserve_cash, capture_cash, release_cash, credit_cash | required |
POST {wallet_base_url}/wallet/balance | balance | none |
POST {wallet_base_url}/wallet/transactions/status | transaction_status | none (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.
| Operation | When Phoenix calls it | Typical reason |
|---|---|---|
reserve_cash | Before a buy order becomes open on the book | ORDER_REQUESTED |
capture_cash | When a buy-side fill consumes reserved cash | ORDER_FILLED |
release_cash | When an order is cancelled, expires, or fills below its maximum cost | ORDER_CANCELLED, ORDER_TERMINATED, PRICE_IMPROVEMENT |
credit_cash | For sell proceeds, maker rebates, and settlement payouts | ORDER_FILLED, MAKER_REBATE, MARKET_SETTLED |
balance | To seed the display on connect, and during reconciliation | n/a (no money move) |
transaction_status | After an uncertain timeout, to learn a move's outcome | echoes 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.
| Field | Type | Required | Description |
|---|---|---|---|
api_version | string | yes | Always "1.0". |
operation | string | yes | reserve_cash | capture_cash | release_cash | credit_cash. |
idempotency_key | string | yes | Opaque, stable, platform-generated. Key your dedup on operator_id + environment + operation + idempotency_key; do not parse it. See Idempotency. |
operator_id | string | yes | Your operator id. |
environment | string | yes | sandbox | prod. |
player.external_id | string | yes | The operator-side player id. Phoenix never sends its internal player UUID across the wallet boundary. |
currency_code | string | yes | The currency the move is denominated in (the session currency Phoenix pinned). |
amount | money | yes | The amount to move. |
reason | string | yes | Informational label (per-operation values above). |
references | object | yes | Correlation 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 }
}
}| Field | Type | Description |
|---|---|---|
api_version | string | Always "1.0". |
status | string | "accepted". A business refusal is not an accepted response with a different status - it is an HTTP 422; see Errors. |
operation | string | Echoes the request operation. |
idempotency_key | string | Echoes the request key. Present on money moves and transaction_status; absent on balance. |
processed_at | number | The balance version, epoch milliseconds. Required - see below. |
| reference id | string | operator_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. |
balance | object | The 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:
| Status | Use when | Phoenix treats as |
|---|---|---|
400 | Payload or required header is malformed | terminal |
401 / 403 | Signature verification failed or the key is not allowed | terminal |
409 | The same idempotency key is still processing | retry |
422 | Business rejection, idempotency-fingerprint mismatch, unknown reservation, or amount exceeds remaining reservation | terminal |
429 | Wallet is rate-limiting Phoenix | retry |
503 | Temporary operator wallet outage | retry |
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.
| Field | Value |
|---|---|
reason | ORDER_REQUESTED |
amount | The 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.
| Field | Value |
|---|---|
reason | ORDER_FILLED |
amount | The 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.
| Field | Value |
|---|---|
reason | ORDER_CANCELLED (cancel), ORDER_TERMINATED (expiry / budget remainder), or PRICE_IMPROVEMENT (per-fill over-hold when a buy fills below its reserved cost) |
amount | The 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.
| Field | Value |
|---|---|
reason | ORDER_FILLED (sell proceeds), MAKER_REBATE, or MARKET_SETTLED (settlement payout) |
amount | The cash to add to available. |
references | Settlement 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.
| Field | Type | Required | Description |
|---|---|---|---|
api_version | string | yes | Always "1.0". |
operation | string | yes | "balance". |
operator_id | string | yes | Your operator id. |
environment | string | yes | sandbox | prod. |
player.external_id | string | yes | The operator-side player id. |
currency_code | string | yes | The 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.