In-game economy
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 |
|
Buy an item | Signed-in player, from your game |
|
Spend a unit of a consumable | Your game |
|
Check what a player holds | Your game |
|
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 |
|---|---|---|
| string | 1–64 characters: letters, digits, |
| string | 1–80 characters |
| string | Optional, up to 300 characters |
| integer | 0 to 1,000,000. |
| boolean | Optional, defaults to |
| string | Optional. |
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 | Charges again and adds more units |
| Always 1 (passing anything else is an error) | 1–99 units per purchase |
Using it up | Nothing to use up |
|
In |
| The units left, which can reach |
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 |
|---|---|---|
|
|
|
| none |
|
| none |
|
: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}'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
descriptionclears it.Leaving out
activesets the item back to active.Leaving out
kindis 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 fromlistItemsand makespurchasefail withnot_found. Players who already hold it keep the entitlement. It still appears ingetEntitlements, and units of a deactivated consumable can still be spent withconsume— 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
consumelooks 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):
The platform looks up an active item with that SKU on this game. If there isn't one, the result is
{ error: "not_found" }.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" }.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.
The result is
{ ok: true, purchased: sku, purchaseId, kind, quantity, coins }, wherequantityis the total the player now holds (always 1 for a durable) andcoinsis 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: trueand 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 carriesreplayed: true. Reusing a key for a different SKU is rejected with{ error: "idempotency_conflict" }.A
nullresult (timeout) doesn't mean the purchase failed. With a key, just retry the same call. Without one, checkgetEntitlements()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
economyscope, or an owner session):
Method and path | Body | Returns |
|---|---|---|
| none. Query: |
|
|
|
|
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. Therefundableflag 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 | 1,000 |
Purchases | 60 per minute per player (exceeding it returns |
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 |
SDK messages to the page | 30 per second across all SDK calls; extras are silently dropped |
Guests
Call | Guest result |
|---|---|
| Works normally, since the store is public |
|
|
|
|
|
|
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.
Related
Tournaments: funding prize pools with coins - XP, achievements & social: how players earn coins - Publisher SDK, REST API overview, API keys & scopes