In-game economy

Embed
Preview of the card
<a href="https://coolgptgames.com/guides/in-game-economy"><img src="https://coolgptgames.com/guides/_api/public/orgs/062b11d9-2b86-4627-9e86-96816a67d7a1/spaces/680f0c4e-f8bc-4af0-983c-c373db913597/pages/29af5cd7-b6a9-4c61-97db-92e7ce777b06/social-card.svg?v=3e7ae33eec8b" alt="In-game economy" width="600" height="315"></a>

You can sell items inside your game for coins, the platform-wide virtual currency players earn on Cool GPT Games. You define a small store of items (SKUs) for your game. Players buy them from inside the game with GameSDK.economy.purchase(sku), and the platform records an entitlement: "this player holds this SKU in this game." An item is either a durable (owned once, forever) or a consumable (bought in units and spent as the player uses them).

Coins are a virtual currency with no real-money value. Players can't buy coins with money, and neither players nor creators can cash coins out, exchange them, or redeem them for anything outside the platform. Don't describe coins, items or prizes as having monetary value in your game.

For exact signatures and failure modes, see GameSDK reference: ads, economy & tournaments.

How it fits together

Piece

Who

How

Define items

You (creator)

Your game's dashboard page (Store), the REST API, or the Publisher SDK

Show the store

Your game

GameSDK.economy.listItems()

Buy an item

Signed-in player, from your game

GameSDK.economy.purchase(sku, opts?)

Spend a unit of a consumable

Your game

GameSDK.economy.consume(sku, qty)

Check what a player holds

Your game

GameSDK.economy.getEntitlements()

Receive the coins

You

Each paid sale credits its full total in coins (price × units) to your coin balance

Refund a sale

You (creator)

Your game's dashboard page (Store → Recent sales), the REST API, or the Publisher SDK

Where players get coins

Players earn coins across the whole platform, not per game. For example, they earn coins when XP is granted, from level-up bonuses and daily rewards, from friends' gifts, and from tournament prizes. See XP, achievements & social and Tournaments. Your game can't grant coins directly. It can only spend a player's coins through purchase.

There's no coin top-up or coin purchase. If a player is short of coins, tell them so, and don't send them to buy coins.

Items and SKUs

Each item belongs to one game and is identified by its SKU.

Field

Type

Rules

sku

string

1–64 characters: letters, digits, _, ., -, :. It's unique per game and is your stable identifier, for example skin_gold or level_pack:2.

name

string

1–80 characters

description

string

Optional, up to 300 characters

priceCoins

integer

0 to 1,000,000. 0 makes a free item.

active

boolean

Optional, defaults to true. Only active items are listed and purchasable.

kind

string

Optional. "durable" (the default for a new item) or "consumable". Left out when editing, the item keeps the kind it has.

A game can have up to 200 items. Editing an existing SKU always works, even at the limit. Only new SKUs count toward it.

Durables and consumables

Durable

Consumable

What the player gets

The SKU, once and permanently

A number of units, added to whatever they already hold

Buying it again

Free no-op, returns alreadyOwned: true

Charges again and adds more units

quantity on purchase

Always 1 (passing anything else is an error)

1–99 units per purchase

Using it up

Nothing to use up

GameSDK.economy.consume(sku, qty)

In getEntitlements()

quantity: 1

The units left, which can reach 0

Good durables: skins, cosmetic themes, level packs, a permanent ability, "remove a feature limit".

Good consumables: extra lives, hint tokens, continues, a revive, a one-run booster.

An item's kind is fixed once anyone owns it. You can flip a brand-new SKU between the two, but as soon as one player holds it the change is refused with kind_locked. Flipping it would turn "owned forever" into "spendable units" (or back) under existing buyers. Add a new SKU instead.

Defining items

Dashboard: open your game in the creator dashboard and use the Store section to add, edit, deactivate or delete items.

REST (API key with the economy scope; you must own the game):

Method and path

Body

Returns

POST /v1/dev/games/:id/items

{ sku, name, description?, priceCoins, active?, kind? }

{ ok: true, sku, kind }. Creates the item, or replaces it if the SKU exists.

GET /v1/dev/games/:id/items

none

{ items: [{ id, gameId, sku, name, description, priceCoins, active, kind, createdAt }] }, including inactive items

DELETE /v1/dev/games/:id/items/:sku

none

{ ok: true }

:id is your game's id, not its slug.

curl -X POST https://api.coolgptgames.com/v1/dev/games/$GAME_ID/items \
  -H "authorization: Bearer $API_KEY" \
  -H "content-type: application/json" \
  -d '{"sku":"skin_gold","name":"Gold Skin","description":"Shiny.","priceCoins":250}'

Publisher SDK:

await client.upsertItem(gameId, { sku: "skin_gold", name: "Gold Skin", priceCoins: 250 });
await client.upsertItem(gameId, { sku: "extra_life", name: "Extra Life", priceCoins: 30, kind: "consumable" });
const items = await client.listItems(gameId);      // all items, including inactive
await client.deleteItem(gameId, "skin_gold");

upsertItem takes kind like the REST body, and listItems returns it, so the whole store — durables and consumables — is manageable from the SDK. It also covers sales and refunds; see Refunds.

See Publisher SDK for client setup and API keys & scopes for the economy scope.

Upserts replace the whole item. A POST for an existing SKU overwrites name, description, priceCoins and active:

  • Leaving out description clears it.

  • Leaving out active sets the item back to active.

  • Leaving out kind is the exception: the item keeps the kind it already has.

Always send every field you want to keep.

Changing, deactivating and deleting items

  • Price changes take effect for the next purchase. Players who already own the item aren't affected. - Deactivating (active: false) hides the item from listItems and makes purchase fail with not_found. Players who already hold it keep the entitlement. It still appears in getEntitlements, and units of a deactivated consumable can still be spent with consume — retiring an item never strands units a player paid for.

  • Deleting removes the item definition only. Existing entitlements aren't removed. Players keep holding the SKU, but a deleted consumable can no longer be consumed, because consume looks the item up. If you later re-create the same SKU, those players already hold it.

Use deactivation, not deletion, to retire an item, and keep honouring held SKUs in your game code.

Using the store in your game

async function openStore() {
  const [items, ent] = await Promise.all([
    GameSDK.economy.listItems(),
    GameSDK.economy.getEntitlements(),
  ]);
  const held = new Map(((ent && ent.entitlements) || []).map(function (e) { return [e.sku, e.quantity]; }));
  renderStore((items || []).map(function (it) {
    return {
      sku: it.sku,
      name: it.name,
      price: it.priceCoins,
      kind: it.kind,                               // "durable" | "consumable"
      owned: it.kind === "durable" && held.get(it.sku) > 0,
      units: held.get(it.sku) || 0,                // what they already hold
    };
  }));
}

async function buy(sku, button, quantity) {
  button.disabled = true;                          // never fire two purchases at once
  const r = await GameSDK.economy.purchase(sku, quantity ? { quantity: quantity } : undefined);
  button.disabled = false;

  if (r && r.ok) {
    if (r.kind === "consumable") setUnits(sku, r.quantity);   // total units now held
    else unlock(sku);                                         // purchased or alreadyOwned
    showBalance(r.coins);
  } else if (r && r.error === "sign_in_required") {
    showMessage("Sign in to buy items.");
  } else if (r && r.error === "insufficient_coins") {
    showMessage("Not enough coins yet. Keep playing to earn more!");
  } else if (r === null) {
    // Timed out or no answer. The purchase MAY have gone through, so re-check.
    const ent = await GameSDK.economy.getEntitlements();
    if (ent && ent.owned.indexOf(sku) !== -1) unlock(sku);
  } else {
    showMessage("Purchase failed. Please try again.");
  }
}

Spending a consumable

Units aren't spent when the player buys them. They're spent when your game says so, with consume:

async function useExtraLife() {
  const r = await GameSDK.economy.consume("extra_life", 1);
  if (r && r.ok) {
    setUnits("extra_life", r.remaining);           // units left after this call
    revivePlayer();
    return true;
  }
  if (r && r.error === "insufficient_quantity") showStore("extra_life");
  return false;                                    // also covers null (timeout)
}

consume is atomic and never goes below zero: asking for more units than the player holds fails with insufficient_quantity and spends nothing. Spend first, then grant the effect, as above, so a failed call can't give away a free life.

Only consumables can be consumed. Calling it for a durable resolves { error: "not_consumable" }.

Unlocking on load

Call getEntitlements() when the game starts. It resolves:

{
  owned: ["skin_gold", "extra_life"],              // SKUs held right now
  entitlements: [
    { sku: "skin_gold",  kind: "durable",    quantity: 1 },
    { sku: "extra_life", kind: "consumable", quantity: 3 },
  ]
}

Unlock everything in owned and read consumable balances from entitlements. owned lists only SKUs the player actually holds, so a consumable spent down to 0 drops out of it while still appearing in entitlements with quantity: 0.

Ownership follows the player across devices and sessions without any save data. Don't store "owned" flags or unit counts in your own save as the source of truth. The entitlement list is authoritative.

Purchase semantics

When a signed-in player calls purchase(sku, opts):

  1. The platform looks up an active item with that SKU on this game. If there isn't one, the result is { error: "not_found" }.

  2. It checks the quantity. Consumables take 1–99 units per purchase (default 1). A durable takes only 1: any other value is { error: "bad_quantity" }.

  3. Everything else happens in one atomic transaction: - The sale is recorded, then the entitlement. For a durable the player already owns, nothing else happens and the result is { ok: true, alreadyOwned: true, kind: "durable", quantity: 1, coins }. They aren't charged. For a consumable, the units are added to whatever they hold.

    • If the total price (unit price × quantity) is above 0, it's debited from the player's coins and the same number of coins is credited to the game's creator. If the balance is too low, the result is { error: "insufficient_coins" } and the whole transaction is undone: nothing is charged, no sale and no units are recorded.

    • If the total is 0, there's no coin movement.

    Either all of it happens or none of it does.

  4. The result is { ok: true, purchased: sku, purchaseId, kind, quantity, coins }, where quantity is the total the player now holds (always 1 for a durable) and coins is their balance after the purchase.

The SDK fires the purchase event only when the purchase succeeded (ok: true, including alreadyOwned: true). It doesn't fire for { error } results or a null timeout. See the reference.

Purchases need the player's own signed-in session. Spending a player's coins is only allowed from the player's signed-in session on Cool GPT Games. Inside the game page that's automatic: the page makes the request with the player's session, and your game never handles it. The purchase and consume endpoints both refuse API keys (403 session_required), so you can't buy or spend items on a player's behalf from your own server.

Double-purchase protection and idempotency

  • Repeats of a durable are safe. Buying a durable the player owns returns alreadyOwned: true and charges nothing, so a "Buy" button that the player taps again after success is harmless.

  • Concurrent purchases of the same durable can't double-charge. Only one of them records the entitlement and is charged. The others resolve alreadyOwned: true.

  • Repeats of a consumable are real purchases. A second tap buys more units and charges again — there's no "already owned" safety net. Disable the Buy button until the promise settles, and use an idempotency key for retries. - Idempotency keys. Pass purchase(sku, { idempotencyKey }) with a value your game generates once per buy attempt (1–64 characters of letters, digits, _, ., - or :). Retrying with the same key replays the original purchase instead of charging again, and the result carries replayed: true. Reusing a key for a different SKU is rejected with { error: "idempotency_conflict" }.

  • A null result (timeout) doesn't mean the purchase failed. With a key, just retry the same call. Without one, check getEntitlements() first — for a consumable there's no other way to tell a lost request from a completed one.

const key = "buy-" + Date.now() + "-" + Math.random().toString(36).slice(2);
let r = await GameSDK.economy.purchase("extra_life", { quantity: 5, idempotencyKey: key });
if (r === null) r = await GameSDK.economy.purchase("extra_life", { quantity: 5, idempotencyKey: key });
// The retry replays the first purchase: r.replayed === true, charged once.

Refunds

Creators can refund an item sale. The player gets their coins back, the sale is clawed back from your coin balance, and the entitlement is revoked — a durable is removed, and a consumable loses the units that purchase added (never dropping below 0, so units already spent aren't clawed back twice).

  • From the dashboard: open your game and use Store → Recent sales. - From REST (API key with the economy scope, or an owner session):

Method and path

Body

Returns

GET /v1/dev/games/:id/purchases

none. Query: limit (1–100, default 50), cursor, sku

{ purchases: [{ id, sku, kind, quantity, priceCoins, status, createdAt, refundedAt, refundReason, unitsRevoked, playerHandle, refundable }], refundWindowDays, nextCursor }, newest first

POST /v1/dev/games/:id/purchases/:purchaseId/refund

{ reason? } (up to 200 characters)

{ ok: true, purchaseId, refundedCoins, unitsRevoked }

  • From the Publisher SDK:

const { purchases, refundWindowDays, nextCursor } =
  await client.listPurchases(gameId, { limit: 25, sku: "extra_life" });

for (const p of purchases) {
  if (p.refundable && p.playerHandle === "alice") {
    await client.refundPurchase(gameId, p.id, "Bought by mistake");
  }
}

Rules:

  • You can refund a sale within 30 days. After that, refund_window_closed. The refundable flag on each row already accounts for it. Site admins can refund at any age.

  • A purchase refunds at most once. A second attempt returns already_refunded (409).

  • You must still hold the coins. The refund debits your balance, so if you've already spent the sale it fails with insufficient_coins.

  • Up to 30 refunds per hour per creator.

  • Refunding doesn't stop the player buying the item again, and nothing is sent to your game. Re-read getEntitlements() when the game next starts, and never cache ownership permanently in your own save.

Deleting or deactivating an item doesn't refund anyone. Describe items accurately before sale.

Limits

Limit

Value

Items per game

200

Units per consumable purchase

99

Units per consume() call

1,000

Purchases

60 per minute per player (exceeding it returns rate_limited)

Consume calls

60 per minute per player, counted separately from purchases

Creator refunds

30 per hour, within 30 days of the sale

SDK response timeout

6 seconds per call; after that the promise resolves null

SDK messages to the page

30 per second across all SDK calls; extras are silently dropped

Guests

Call

Guest result

listItems()

Works normally, since the store is public

getEntitlements()

{ owned: [] }

purchase(sku)

{ error: "sign_in_required" }, with no charge and no entitlement

consume(sku, qty)

{ error: "sign_in_required" }, with nothing spent

Show guests the store with prices, but prompt them to sign in when they try to buy.

What you earn from sales

Each paid sale credits the full total, in coins, to your own coin balance — the unit price for a durable, the unit price × units for a consumable. That's the same balance you can spend as a player or use to fund tournament prize pools (see Tournaments). Refunding a sale takes those coins back out of your balance, which is why a refund fails if you've already spent them. Coins from sales are not money and aren't part of your ad-revenue earnings or payouts. See Ads and rewarded ads for how creator payouts work.

Updated