Phoenix Prediction Docs

Idempotency

Prevent duplicate wallet movement when Phoenix retries

Wallet operations can be delivered more than once. If your backend processes the same idempotency key twice, players can be charged, reserved, released, captured, or credited incorrectly.

Duplicates must be successful no-ops

When a completed operation repeats with the same idempotency key and the same request fingerprint, return the stored result and do not change the player balance again.

Scope

Idempotency is scoped by:

operator_id + environment + operation + idempotency_key

Retries must reuse the same key. A new key means a new intended money movement. The one new key you will see for a move Phoenix asked for before is a resend after your wallet refused that move for 24 hours: the refused key moved nothing, so the resend asks for the same move under a new key (see Sending a refused move again).

Required Behavior

For every money-moving operation:

Verify the Phoenix signature.

Start a database transaction.

Look up the idempotency key and operation in your wallet transaction table.

If the same key and same request fingerprint completed before, return the stored result.

If the same key is still processing, wait for the first request to finish and return its stored result. Answering 409 or 503 instead also works: Phoenix sends the move again under the same key.

If the same key is reused with a different request fingerprint, return 422.

Lock the player balance or reservation rows, or use an atomic balance update.

Apply reserve_cash, release_cash, capture_cash, or credit_cash once.

Store the request body hash, response body, final balance, operation status, and processed timestamp.

Commit.

Store the Received Key

You do not construct idempotency keys. Phoenix generates the key and sends it in the body's idempotency_key and in the lowercase idempotency-key header too, on production calls and wallet test calls alike. Dedup on the body field.

Treat the key as opaque

Never parse the idempotency key or branch on its internal format. The key is a stable platform-generated string; its shape is internal and not a contract. Key your dedup on the tuple, not on anything you decode out of the key.

To reconcile a move back to a Phoenix order, join on the small references set Phoenix sends in the body, not on the key:

Operationreferences keys
reserve_cash, capture_cash, release_cashorder_id
credit_cash for sell proceeds, a merge, or a maker rebate on a matched tradetaker_order_id, maker_order_id
credit_cash maker rebate on a mintorder_id
credit_cash for settlement payoutclaim_side
credit_cash correction (REFUND_ROUNDING_CORRECTION)payout_id, claim_side
Every move, under wallet API 1.1also venue_id, market_id, outcome, final, and mode on a final move (Positions)

A correction is a new credit_cash move with its own reason, idempotency key, and references. See Reconciliation for the full join model.

Request Fingerprint

Store a fingerprint for the first request body:

sha256(canonical_json(request_body))

The same key with the same fingerprint returns the first completed result. The same key with a different fingerprint returns 422.

Retries and Status Lookup

When a money move times out, fails in transit, or gets a temporary answer (408, 409, 429, 5xx), Phoenix sends the same move again: same body, same idempotency key. Your stored result answers it, and nothing moves twice. Keys are opaque strings of about 100 characters at most and may contain : and .; store them as sent (a column of 255 characters is ample).

Phoenix does not call POST /wallet/transactions/status. If you implement it for your own reconciliation, it carries the same envelope as the original move, must never move money, and returns the stored result (or the stored refusal) for the key; for a key you have no record of, answer with a 4xx and a JSON body. See transaction_status.

SQL Pattern

Key the lookup on the idempotency key Phoenix sent, scoped by (operator_id, environment, operation, idempotency_key).

BEGIN;

SELECT * FROM wallet_transactions
WHERE operator_id = $1
  AND environment = $2
  AND operation = $3
  AND idempotency_key = $4
FOR UPDATE;
-- if found with same fingerprint and completed, return stored response
-- if found with different fingerprint, return 422
-- if found and processing, wait for it and return its stored response

SELECT balance FROM players WHERE external_id = $5 FOR UPDATE;
-- validate and update balance once

INSERT INTO wallet_transactions (
  operator_id,
  environment,
  operation,
  idempotency_key,
  external_id,
  amount_value,
  amount_scale,
  currency_code,
  request_fingerprint,
  response_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);

COMMIT;

Use your own schema, but keep the same behavior: one received idempotency key, one wallet effect.