triggair

Blog / economy

Sell items with a server-authoritative store

3 min read

Sell items with a server-authoritative store cover art

You want to sell a sword for 500 gold. The naive version is a disaster waiting to happen: the client reads a price, subtracts the currency, adds the item, and syncs. Every step of that lives in the player's browser, which means every step is editable. Set the price to zero, grant yourself the item, spoof the balance: all trivial in a console. And even honest players will bite you: a flaky connection retries the request and now they've paid twice.

The fix is to never let the client decide anything that matters. The client's only job is to say "I want to buy listing X." The server owns the price, the balance check, the spend, and the grant, all as one atomic transaction.

Start by reading what's for sale. Stores and their listings are defined in your dashboard, so your UI just renders whatever comes back.

ts
const stores = await tg.economy.stores();       // [{ key, name }]
const listings = await tg.economy.listings(stores[0].key);
// [{ id, item_id, grant_qty, price: [{ currency, amount }] }]

Notice the price comes from the server. You render it, but you never send it. To buy, you pass only the store key and the listing id.

ts
const res = await tg.economy.buy(storeKey, listingId);
if (res.kind === 'applied') {
  console.log('purchased', res.txn_id, res.lines);
} else if (res.kind === 'replay') {
  // idempotent hit - the original purchase already happened
  console.log('already processed', res.txn_id);
}

buy() spends the currency and grants the item in a single atomic step: either both happen or neither does. It's also idempotent: if the network hiccups and the SDK retries, the server recognizes the repeat and returns kind: 'replay' with the same txn_id instead of charging again. You get exactly-once purchasing for free.

The one error you'll actually design around is a player who can't afford it. Catch it and turn it into a nudge, not a crash.

ts
try {
  await tg.economy.buy(storeKey, listingId);
} catch (err) {
  if (err.code === 'insufficient_funds') {
    showToast("Not enough gold - earn more or check the coin store");
  } else {
    // every error carries err.message and err.agentHint
    throw err;
  }
}

After a purchase, read the player's inventory and wallet straight from the source of truth:

ts
const inv = await tg.economy.inventory();  // [{ item_id, qty, equipped }]
const wallet = await tg.economy.wallet();  // [{ currency, balance }]

await tg.economy.equip(inv[0].item_id);    // wear the new sword
await tg.economy.consume(potionId, 1);     // drink one potion

Gotchas

  • Purchase limits and stock are dashboard-configured, not client-enforced. If a listing is one-per-player or limited stock, buy() will reject the over-limit attempt server-side with a typed error. You don't (and can't reliably) gate that in the client. Read the code and message it to the player.
  • Don't cache the wallet. Currency is server-authoritative; re-read wallet() after any spend rather than mutating a local number, or your UI will drift from reality.
  • Treat replay as success. It means the purchase went through, just not on this attempt. Show the item, don't show an error.

Because currency and inventory only change through validated server paths, there's no client surface to forge. The player asks; the server decides, charges, and grants, atomically and once. That's the whole game: keep the money on the server, and buying stops being something you have to defend.

Build this into your game.

Everything above is one import and a publishable key away. Start with the quickstart, or read the guide for the exact feature.

Keep reading