Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Economy

This page describes how in-game money is held and moves in Open Play: currency definitions, wallets, the ledger, player-to-player transfers, NPC merchants and player trading. Read it if you are looking for the safe way to give players money, let them spend it and monitor the economy.

What it does

The economy spans two modules:

  • economy: currencies, wallets, the ledger, transfers and reports.
  • trading: NPC merchants (merchants) and player-to-player trading (trades).

Every money movement is double-entry: the amount leaving one wallet enters another wallet or a system account, and both sit in the ledger under the same transaction ID (tx_id). The ledger is append-only; no row is updated or deleted. To correct a mistake, a reversing entry is written.

If a module is disabled, the endpoints return 404 with {"code":"module_disabled","module":"economy"} (or trading).

Where it lives in the panel

What you want to doPanel page
Create a currency; set the icon, decimal places and supply capCurrencies, Currency profile
See the daily summary, balance ranking, trades and wallet lookupEconomy reports
See a player’s wallet and movementsPlayer profile
Set up a reward tableReward tables
Tie a product to money and sell itStore

Note

Panel help pages are not translated yet; the panel links above point to the Turkish source.

Data model (in plain words)

  • Currency: Has a key (gold), a name, transferable (whether player-to-player transfer is allowed), max_supply (the total supply cap; unlimited if absent) and an icon. Registering again with the same key produces a new version; old versions do not change. The decimal places (display_decimals) and the icon image are set in the panel; a REST registration leaves them at 0/empty.
  • Wallet: An owner’s balance in one currency. The owner has the form <kind>:<uuid> (player, character, clan, system). There is no wallet write endpoint; a wallet is born by itself on its first movement and is bound to the currency version it was born with.
  • Balance: An integer in the smallest unit (i64). There are no decimals: to display “12.50 gold”, apply the decimal places set in the panel in your client.
  • Ledger row: direction (in/out), amount, reason (transfer, grant, burn, merchant_purchase, merchant_sale, iap, iap_revoke, party_reward, clan_treasury, quest, login, npc_reward, reward_table, trade_lock, trade_refund, trade_settle …) and reference (context).
  • Merchant: An NPC with a key, a name and per-item buy/sell price overrides (price_overrides). The item’s own npc_buy_price/npc_sell_price is the default.
  • Trade: A state machine between two players: draft → offered → locked → completed or cancelled/expired.

How to give a player money

There is no REST endpoint that directly “adds this much to a wallet”. Money enters a player’s account through these routes:

RouteWhen
Reward table grant (POST /v1/reward-tables/{key}/grant)Money the game gives, such as quests, tournaments and login rewards — see Rewards
Daily login reward, quest, NPC rewardRewards
In-app purchase (IAP)Game money bought with real money — see Store
Selling an item to a merchant (POST /v1/merchants/{key}/sell)A player sells an item to an NPC
Party reward distribution, clan treasurySocial
Manual grant from the panelAn operator; the operation is written to the audit trail

Each shows up in the ledger with its own reason value.

Calls your game makes

Endpoints are called with Authorization: Bearer crx_live_<prefix>_<secret>. Currency and merchant management needs a server key (403 otherwise); reads and player operations can be done with any API key.

Method and pathKeyWhat it does
POST /v1/currenciesserverSaves a currency as a new version
GET /v1/wallets?owner=player:<uuid>API keyThe owner’s wallets (one row per currency)
POST /v1/transfersAPI keyWallet-to-wallet transfer (Idempotency-Key required)
GET /v1/ledger?wallet_id=<uuid>API keyA wallet’s ledger stream (cursor, limit)
POST /v1/merchantsserverSaves a merchant as a new version
GET /v1/merchants/{key}API keyThe merchant’s current content
POST /v1/merchants/{key}/buyAPI keyA player buys an item from the merchant
POST /v1/merchants/{key}/sellAPI keyA player sells an item to the merchant
POST /v1/tradesAPI keyOpens a trade draft
GET /v1/trades/{id}?actor_id=API keyViews a trade (only the two parties)
POST /v1/trades/{id}/offerAPI keyLocks the legs
POST /v1/trades/{id}/acceptAPI keyFinal confirmation; atomic exchange
POST /v1/trades/{id}/cancelAPI keyCancels; locks are released and money is refunded
GET /v1/economy/faucet-sink, /flags, /merchant-gap, /top-holdersserverDaily economy reports
GET /v1/auth/admin/player-wallet-movements?player_id=serverA player’s recent wallet movements (limit 1–100, default 20)

Note

The TypeScript SDK has no typed client for these modules; call them with raw REST. See TypeScript SDK.

Warning

Known limitation: The transfer, merchant buy/sell and trade endpoints do not look at the key type; a valid client key can call them too, and the wallet/player IDs are given freely in the request. A client key is scoped to the environment, not to the player. Make money movements only from your own game server and verify the ID coming from the player there.

Currency registration

POST /v1/currencies
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json

{ "key": "gold", "name": "Gold", "transferable": true, "max_supply": 1000000000 }
{ "id": "0192f0aa-1000-7000-8000-000000000001", "key": "gold", "version": 1, "name": "Gold", "transferable": true, "max_supply": 1000000000, "icon": null }

key and name cannot be empty; if max_supply is given it must be greater than 0 (400). A currency with transferable: false returns 422 on a player-to-player transfer (in-game spending still works).

Transfer

POST /v1/transfers
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json
Idempotency-Key: transfer-2026-11-03-0001

{ "from_wallet": "0192f1…", "to_wallet": "0192f2…", "amount": 500, "reference": { "reason": "gift" } }
{ "tx_id": "0192f3…", "from": { "id": "0192f1…", "balance": 1500, "version": 7 }, "to": { "id": "0192f2…", "balance": 500, "version": 3 }, "replayed": false }

(The from and to in the response contain the full fields of the wallet records; they are abbreviated above.) Rejected cases: an amount of 0 or negative 400; different currencies 400; wallet not found 404; insufficient balance, transferable: false, or the same key with a different body 422; the same key in flight 409.

Merchant buy and sell

POST /v1/merchants/blacksmith/buy
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json
Idempotency-Key: buy-player-1234-17

{ "player_id": "0192f0f2-…", "item_key": "iron_sword", "currency_key": "gold", "quantity": 1 }

Response: { "event": {…}, "wallet_balance": 900, "wallet_version": 8, "inventory_id": "…", "inventory_version": 5, "replayed": false }.

A purchase, in one transaction, deducts from the wallet, creates the item as a new instance, places it in the first empty slot and reduces the stock. 422 if the price is undefined, the balance is insufficient or the inventory is full; 409 if the stock is insufficient. For a sale (sell), the body carries { "player_id", "item_instance_id", "currency_key" } and the whole stack is sold; for a partial sale, apply split first. Idempotency-Key is required on both.

Player trading

  1. POST /v1/trades — open a draft with { "initiator_player_id", "counterparty_player_id", "ttl_ms" } (ttl_ms > 0; when the time runs out the trade becomes expired).
  2. Both parties lock their legs with POST /v1/trades/{id}/offer: { "actor_id", "legs": [ {"type":"item","item_instance_id":"…"}, {"type":"currency","currency_id":"…","amount":100} ] }. An item must be in an inventory, tradable and not locked in another trade (409 if locked); money is taken into escrow (a holding account).
  3. When both parties have locked, the trade becomes locked. POST /v1/trades/{id}/accept ({ "actor_id" }) works only with the initiating player and exchanges all legs in one transaction. If the recipient’s inventory has no room, 422 is returned and the trade stays locked.
  4. POST /v1/trades/{id}/cancel can be called by either party; locks are released and money is refunded.

If someone who is not a party calls GET /v1/trades/{id}, they get 404 (the trade’s existence is hidden); the actor_id query parameter is required.

Versioning and replay safety

  • All of the transfers, merchants/{key}/buy|sell and trades commands require Idempotency-Key (400 if absent). The same key and the same body do not repeat the operation and return replayed: true; a different body is 422. The key is burned only on success.
  • Currency and merchant registration writes a new version each time; old versions are untouchable.
  • Ledger reads are cursor-based (limit 1–200, default 50).

Reports

The report endpoints (faucet-sink, flags, merchant-gap, top-holders) are computed over closed days in UTC: from and to are inclusive and a range of at most 366 days can be given. Missing days are listed in the response as missing_dates; they are not counted as zero. iap_net is game money, not real money. flags is a review signal; no automatic sanction is applied.

Events

Money movements write events to the outbox. Among the types you can select as a webhook filter there is no economy-specific type; endpoints registered without a filter receive all events and so receive money events too, but the envelope format of these events is not a documented contract. Store and reward events are selectable types: Webhooks.

Limits

  • Balances and amounts are i64 smallest units; an amount must be greater than 0.
  • Production that exceeds the currency’s max_supply cap (reward, IAP, sale) returns 422.
  • At most 366 days in reports.

Common errors

SymptomCauseFix
400 (Idempotency-Key)The header is missingSend a key on every write
403Currency/merchant registration with a client keyUse a server key
404 walletThe wallet ID given in the transfer does not existRead the ID with GET /v1/wallets; if the owner has never had a movement in that currency, the wallet is not born yet and the list comes back empty
422 insufficient balanceThe balance is less than the amountRead the balance with GET /v1/wallets
422 transferable=falseThe currency cannot be transferredEnable transfer on the currency or use another route
409 stockThe merchant’s stock is not enoughReduce the quantity

Working example: grant money, then buy a product

The server gives the player money from a reward table; the player buys a product from the store with their own session. The first step runs on the server, the second in the game client. The welcome_pack table must already be set up from the panel (Reward tables) or with POST /v1/reward-tables.

/** Ekonomi akışı: sunucu ödül tablosundan para verir, oyuncu kendi oturumuyla mağazadan ürün alır. */
import { CrxClient, StoreBuyErrorCode, storeBuyErrorCode, type PlayerClient } from "@ferdiunal/open-play-ts";

/** 1. adım (güvenilir sunucu): oyuncuya tablodan ödül verir. Aynı `requestKey` ile tekrar ödeme yapmaz. */
export async function grantWelcomeReward(baseUrl: string, serverApiKey: string, playerId: string, requestKey: string): Promise<void> {
  const crx = new CrxClient({ baseUrl, apiKey: serverApiKey });
  const grant = await crx.grantRewardTable("welcome_pack", {
    playerId,
    source: { type: "manual", operatorId: "game-server" },
    idempotencyKey: requestKey,
  });
  // `rewards` verilen birleşik ödüllerdir; `replayed: true` ikinci çağrının aynı sonucu geri verdiği anlamına gelir.
  console.log(grant.rewards, grant.replayed);
}

/** 2. adım (oyun istemcisi): oyuncunun gördüğü ilk ürünü satın alır. */
export async function buyFirstListing(player: PlayerClient, pageKey: string, idempotencyKey: string): Promise<string> {
  const catalog = await player.store.catalog({ page: pageKey });
  const listing = catalog.pages[0]?.listings[0];
  if (listing === undefined || listing.version === null) return "Vitrinde ürün yok.";
  try {
    // Gördüğünüz sürümü geri gönderin: fiyat arada değiştiyse sunucu satmaz (409 listing_changed).
    const result = await player.store.buy(listing.id, { expectedVersion: listing.version, quantity: 1, idempotencyKey });
    return `Satın alındı. Kalan bakiye: ${result.balanceAfter}`;
  } catch (error: unknown) {
    switch (storeBuyErrorCode(error)) {
      case StoreBuyErrorCode.InsufficientFunds: return "Bakiye yetmiyor.";
      case StoreBuyErrorCode.OutOfStock: return "Stok tükendi.";
      case StoreBuyErrorCode.ListingChanged: return "Ürün değişti; mağazayı yenileyin.";
      default: throw error;
    }
  }
}