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 do | Panel page |
|---|---|
| Create a currency; set the icon, decimal places and supply cap | Currencies, Currency profile |
| See the daily summary, balance ranking, trades and wallet lookup | Economy reports |
| See a player’s wallet and movements | Player profile |
| Set up a reward table | Reward tables |
| Tie a product to money and sell it | Store |
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 at0/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…) andreference(context). - Merchant: An NPC with a key, a name and per-item buy/sell price overrides (
price_overrides). The item’s ownnpc_buy_price/npc_sell_priceis the default. - Trade: A state machine between two players:
draft→offered→locked→completedorcancelled/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:
| Route | When |
|---|---|
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 reward | Rewards |
| 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 treasury | Social |
| Manual grant from the panel | An 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 path | Key | What it does |
|---|---|---|
POST /v1/currencies | server | Saves a currency as a new version |
GET /v1/wallets?owner=player:<uuid> | API key | The owner’s wallets (one row per currency) |
POST /v1/transfers | API key | Wallet-to-wallet transfer (Idempotency-Key required) |
GET /v1/ledger?wallet_id=<uuid> | API key | A wallet’s ledger stream (cursor, limit) |
POST /v1/merchants | server | Saves a merchant as a new version |
GET /v1/merchants/{key} | API key | The merchant’s current content |
POST /v1/merchants/{key}/buy | API key | A player buys an item from the merchant |
POST /v1/merchants/{key}/sell | API key | A player sells an item to the merchant |
POST /v1/trades | API key | Opens a trade draft |
GET /v1/trades/{id}?actor_id= | API key | Views a trade (only the two parties) |
POST /v1/trades/{id}/offer | API key | Locks the legs |
POST /v1/trades/{id}/accept | API key | Final confirmation; atomic exchange |
POST /v1/trades/{id}/cancel | API key | Cancels; locks are released and money is refunded |
GET /v1/economy/faucet-sink, /flags, /merchant-gap, /top-holders | server | Daily economy reports |
GET /v1/auth/admin/player-wallet-movements?player_id= | server | A 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
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 becomesexpired).- 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 (409if locked); money is taken into escrow (a holding account). - 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,422is returned and the trade stays locked. POST /v1/trades/{id}/cancelcan 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|sellandtradescommands requireIdempotency-Key(400if absent). The same key and the same body do not repeat the operation and returnreplayed: true; a different body is422. 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 (
limit1–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
i64smallest units; an amount must be greater than 0. - Production that exceeds the currency’s
max_supplycap (reward, IAP, sale) returns422. - At most 366 days in reports.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
400 (Idempotency-Key) | The header is missing | Send a key on every write |
403 | Currency/merchant registration with a client key | Use a server key |
404 wallet | The wallet ID given in the transfer does not exist | Read 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 balance | The balance is less than the amount | Read the balance with GET /v1/wallets |
422 transferable=false | The currency cannot be transferred | Enable transfer on the currency or use another route |
409 stock | The merchant’s stock is not enough | Reduce 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;
}
}
}