TANITARDevelopers
GuidesRewards

Exchanges

How players convert Red into Blue or into a testnet token and back, what each exchange status means, and how you read, reconcile and release exchanges.

An exchange converts value a player holds into something else. Players start exchanges themselves from the Player surface; your integration reads them and acts on the few that need help. Every exchange is one durable record, created before anything moves, and every balance change it makes is a ledger posting committed together with the record.

Kinds

kindFromToAvailable
internalredblue, or backAlways, once you have published both currencies
crypto-payoutredA testnet native token (eth, hbar or sol) sent to the player's addressWhen the chain advertises exchange in GET /v1/web3/capabilities
crypto-depositA native token the player sendsredWhen the chain advertises exchange

The internal rate is fixed: five Blue per Red, one Red per five Blue. Arithmetic is on integers only, so a Blue amount that is not a multiple of five is refused before anything is written. Crypto rates are configured on the deployment and pinned on the record at the moment it is created. Any sub-unit remainder the conversion cannot represent is reported in dust and never credited.

Crypto legs run on testnets only (Ethereum Sepolia, Hedera testnet, Solana). Fiat exchange is not offered: cash leaves the platform only as a payout.

Statuses

flowchart LR
  subgraph Payout
    R[reserved] --> S[submitted]
    S --> OK1[settled]
    S --> F[failed]
    F --> S
    R --> REL1[released]
    F --> REL1
    S --> U1[unresolved]
  end
  subgraph Deposit
    I[intended] --> OK2[settled]
    I --> REL2[released]
    I --> U2[unresolved]
  end
StatusMeaning
settledDone. For a payout, the transfer was proven from chain state.
rejectedAn internal exchange that could not be made; nothing moved.
reservedPayout: the Red is debited into a reserve; no transaction is signed yet.
submittedPayout: a signed transaction exists and its outcome is not yet known.
failedPayout: the attempt failed or expired. The next reconcile opens exactly one new attempt.
intendedDeposit: the player has an address and a nonce to pay into, and nothing has arrived yet.
releasedThe reservation or intent was cancelled and any reserved Red credited back. Final.
unresolvedA transaction succeeded but did not prove the expected transfer. Final; the Red stays reserved because value may have moved.

internal exchanges settle or are rejected in one step. settled, rejected, released and unresolved are final.

Read exchanges

GET /v1/exchanges with scope rewards:read lists your tenant's exchanges, newest first.

Query parameterRule
playerIdOptional; one player
kindinternal, crypto-payout or crypto-deposit
statusAny status above
chainsolana, ethereum or hedera
limit1 to 100, default 50

There is no cursor. An invalid filter is refused with 400 EXCHANGE_QUERY_INVALID.

curl "$REWARD_API_URL/v1/exchanges?kind=crypto-payout&status=submitted" \
  -H "Authorization: Bearer $TANITAR_API_KEY"

GET /v1/exchanges/:exchangeId reads one. An exchange of another tenant answers 404 EXCHANGE_NOT_FOUND, the same as one that does not exist.

{
  "exchangeId": "exc_4b1e…",
  "playerId": "player-7f3a",
  "kind": "crypto-payout",
  "status": "settled",
  "from": { "currencyKey": "red", "amount": "200" },
  "to": { "currencyKey": "eth", "amount": "2000000000000000" },
  "rate": "10000000000000",
  "dust": "0",
  "gameSlug": null,
  "chain": "ethereum",
  "clusterGenesisHash": null,
  "destination": "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
  "treasury": null,
  "nonce": null,
  "txId": "0x9f2c…",
  "attemptCount": 1,
  "failureCode": null,
  "slot": null,
  "confirmedAt": "2026-09-30T11:02:09.412Z",
  "settledAt": "2026-09-30T11:02:10.004Z",
  "requestedBy": "player-7f3a",
  "createdAt": "2026-09-30T11:01:31.771Z",
  "updatedAt": "2026-09-30T11:02:10.004Z"
}

Amounts are decimal strings; token amounts are in the chain's base units. The record never contains signer material or node addresses.

Reconcile a payout

POST /v1/exchanges/:exchangeId/reconcile with scope rewards:mutate drives a crypto-payout forward:

  • reserved: sends the first attempt.
  • submitted: checks the retained transaction on chain first. If it confirmed, the exchange settles; if it is still pending, you get 409 EXCHANGE_PENDING and nothing is re-sent; if it failed or expired, the record is marked failed and exactly one new attempt is sent.
  • failed: sends exactly one new attempt.
  • settled, released, unresolved, or an internal exchange: returns the record unchanged.

A deposit is confirmed by the player, who supplies the transaction id that funded it. Reconciling an intended deposit from your integration is refused with EXCHANGE_DEPOSIT_NOT_PROVEN, because no transaction id is supplied.

Release

POST /v1/exchanges/:exchangeId/release with scope rewards:mutate cancels:

  • a crypto-payout in reserved or failed, crediting the reserved Red back to the player;
  • a crypto-deposit in intended that was never funded.

Releasing an already released exchange returns it unchanged. Any other state is refused with EXCHANGE_NOT_RELEASABLE. A submitted payout cannot be released, because its transaction may still land: reconcile it instead.

What your players see

Players quote, create, confirm and release their own exchanges on the Player surface: GET /player/v1/exchanges/quote, POST /player/v1/exchanges, POST /player/v1/exchanges/:exchangeId/confirm, POST /player/v1/exchanges/:exchangeId/release, and GET /player/v1/exchanges for their history. Creating an exchange requires an Idempotency-Key, so a player's retry never exchanges twice.

When an exchange settles, reward.exchange-settled is published, the player gets an inbox notification, and your webhook endpoint can receive the event (see the event catalog).

On this page