API Docs

GameSDK reference: ads, economy & tournaments

AdminUpdated Sep 22, 2026

GameSDK reference: ads, economy & tournaments

This page is the reference for these GameSDK calls:

Area

Calls

Ads

requestAd

Economy

economy.listItems, economy.purchase, economy.consume, economy.getEntitlements

Tournaments

tournaments.list, tournaments.getActive, tournaments.join, tournaments.getStandings

Events

adStarted, adComplete, adError, purchase, tournamentJoined

For concepts and walkthroughs, see Ads and rewarded ads, In-game economy and Tournaments. For loading the SDK, GameSDK.on, and the core lifecycle calls, see GameSDK core reference.

How these calls behave

All of these calls send a message from your sandboxed game to the Cool GPT Games page, which calls the API for you. Your game never handles the player's credentials. Keep these rules in mind:

  • No call rejects. Every method here returns a Promise that resolves, with an error object or null when something goes wrong. You don't need try/catch, but you do need to check the value.

  • Every call settles. Economy and tournament calls resolve null if the page doesn't answer within 6 seconds. requestAd resolves { error: "timeout" } after 60 seconds. - Message rate limit. The page accepts at most 30 SDK messages per second from your game, counting every SDK call. Extra messages are silently dropped, so the call then times out. - Sign-in comes from the site. "Signed in" means the player is signed in to Cool GPT Games in that browser. Your game can check with GameSDK.getPlayer() (see GameSDK core reference). - Only on Cool GPT Games. Outside the Cool GPT Games player, for example when you open your index.html locally, nobody answers. Economy and tournament calls resolve null after 6 seconds, and requestAd resolves { error: "timeout" } after 60 seconds. Guard for this in local development.


Ads

GameSDK.requestAd(placement)

GameSDK.requestAd(placement?: string): Promise<
  { rewarded: boolean; rewardId?: string } | { error: string; retryAfterSec?: number }
>

Asks the page to show an ad over your game. The promise settles exactly once.

Parameters

Name

Type

Required

Notes

placement

string

No

"rewarded" requests a rewarded ad. Any other value, or none, requests a "midroll" (interstitial) ad. No other placement values exist for games.

Resolves

Value

When

{ rewarded: true, rewardId }

A rewarded ad was watched to the end of its 5-second countdown, and the ad server confirmed the reward. rewardId is a single-use receipt your own server can verify.

{ rewarded: false }

A rewarded ad was closed early or the ad server didn't confirm the reward, or a midroll finished (midrolls never reward). No rewardId.

{ error: "capped", retryAfterSec? }

The placement's frequency cap blocked it (see Limits). retryAfterSec is the whole seconds until the next one is allowed, and is present only when the minimum-spacing rule was what blocked it — not when the per-session allowance is used up.

{ error: "not_configured" }

No ad tag is configured for that format. Midroll/interstitial today.

{ error: "no_fill" }

No ad was available

{ error: "no_consent" }

The visitor hasn't allowed ads (banner unanswered, rejected, or the consent tool couldn't load)

{ error: "no_session" }

Midroll only: requested before GameSDK.start() or GameSDK.replay.ready() began a play session

{ error: "busy" }

Another ad is still in progress. That earlier ad and its promise carry on unaffected.

{ error: "timeout" }

The page didn't answer within 60 seconds

{ error: "error" }

Network or other failure

{ error: "preview" }

The build is running in a creator preview, which never requests a real ad

Events fired

Case

Events

Ad shown

adStarted, then adComplete { rewarded, rewardId? }

capped, not_configured, no_fill, no_consent, no_session, preview, error

adError { reason, retryAfterSec? } only. retryAfterSec accompanies capped when the answer carried one.

busy

adError { reason: "busy" } only. The ad already running keeps its own events.

timeout

adError { reason: "timeout" }, fired by the SDK

Verifying a rewarded result. rewardId is recorded server-side when the ad network confirms the reward. Your own backend verifies it once with GET /v1/dev/games/:id/ad-rewards/:rewardId (an API key with the economy or analytics scope), which returns the receipt plus alreadyVerified so a replayed id is detectable. The same receipt is pushed as the ad.rewarded webhook event. See Ads and rewarded ads for the full flow and an example.

Failure behaviour

  • The promise never rejects, and it settles exactly once: with the ad's result, or with one error.

  • 60-second timeout. If the page never answers (the message was dropped, or you're not running on Cool GPT Games), the promise resolves { error: "timeout" } and adError { reason: "timeout" } fires. If the page's answer arrives later anyway, its adStarted / adComplete / adError events still fire, but the promise has already settled.

  • One ad at a time. Calling requestAd while an earlier ad is in progress resolves the new call with { error: "busy" } straight away. The earlier call isn't affected and still settles with its own result. (An ad that has been in progress for more than 90 seconds no longer blocks a new one.)

Sign-in: not required. Guests get ads and rewarded results the same way as signed-in players.

Limits

Frequency caps are enforced by the API before any ad is fetched. They're counted against the current play session; with no session (a rewarded ad from a menu) they're counted per viewer per game over a rolling 30-minute window.

Placement

Cap

Over the cap

"rewarded"

12 per session

{ error: "capped" }

"rewarded"

at least 15 seconds apart

{ error: "capped", retryAfterSec }

"midroll"

4 per session

{ error: "capped" }

"midroll"

at least 3 minutes apart

{ error: "capped", retryAfterSec }

A request nobody reports an outcome for still counts against the cap. A request that ends in no_fill or an error doesn't.

Only the spacing rules produce a retryAfterSec. A used-up per-session allowance doesn't, because it doesn't reset until the next play session.

Ad requests and ad events are also limited to 60 per minute per play session (or per viewer per game when there's no session).

Example

async function watchForReward() {
  button.disabled = true;
  const r = await GameSDK.requestAd("rewarded");   // always settles, at most 60 s
  button.disabled = false;
  if (r.rewarded === true) grantBonus(r.rewardId); // pass rewardId to your server to verify
  else if (r.error === "no_consent") toast("Allow ads to earn this reward.");
  else if (r.error === "capped") {
    if (r.retryAfterSec) toast("Another ad in " + r.retryAfterSec + "s.");
    else button.remove();                          // allowance used up for this session
  }
}

Pitfalls

  • Grant rewards on rewarded === true only. adComplete also fires for closed, unconfirmed and midroll ads.

  • The platform doesn't pause your game. Pause on adStarted and resume on adComplete or adError, and make resuming safe to run twice. Note that adError { reason: "busy" } fires while another ad is still showing, so don't resume on busy. Disabling your ad button until the promise settles avoids it entirely.

  • The pause and resume events are about tab visibility, not ads.

  • The platform grants nothing for a rewarded ad. The reward is whatever your game gives.

  • Midroll is implemented but no interstitial ad tag is configured yet, so midroll requests resolve { error: "not_configured" } today. Never make progress depend on one.

  • rewarded: true without a rewardId can happen if the receipt couldn't be recorded. Treat the reward as client-only in that case, and don't hand a missing id to your backend.


Economy

Items are defined per game by the creator. See In-game economy. Prices are in coins, a virtual currency with no real-money value.

GameSDK.economy.listItems()

GameSDK.economy.listItems(): Promise<Item[] | null>

interface Item {
  sku: string;
  name: string;
  description: string | null;
  priceCoins: number;             // integer ≥ 0
  kind: "durable" | "consumable"; // durable = owned once; consumable = bought in units
}

Returns this game's active store items.

Parameters: none.

Resolves: an array of Item. The array order isn't guaranteed, so sort it yourself.

Failure behaviour: resolves [] if the API returns an error, and null on a network error or after the 6-second timeout.

Sign-in: not required. Guests see the same list.

Example

const items = (await GameSDK.economy.listItems()) || [];
items.sort(function (a, b) { return a.priceCoins - b.priceCoins; });
items.forEach(function (it) { addRow(it.name, it.priceCoins + " coins", it.sku); });

Pitfalls: inactive or deleted items aren't listed, but players may still hold them. Always combine this list with getEntitlements().


GameSDK.economy.purchase(sku, opts?)

GameSDK.economy.purchase(
  sku: string,
  opts?: { quantity?: number; idempotencyKey?: string },
): Promise<PurchaseResult | { error: string } | null>

type PurchaseResult =
  | { ok: true; purchased: string; purchaseId: string; kind: ItemKind; quantity: number; coins: number }
  | { ok: true; alreadyOwned: true; kind: "durable"; quantity: 1; coins: number }
  | { ok: true; replayed: true; purchased: string; purchaseId: string; kind: ItemKind;
      status: string; quantity: number; coins: number };

type ItemKind = "durable" | "consumable";

Buys an item for the signed-in player with their coins. A durable is owned permanently. A consumable adds units the game spends with consume.

Parameters

Name

Type

Required

Constraints

sku

string

Yes

Must match an active item on this game. 1–64 characters. The value is converted with String().

opts.quantity

number

No

Units to buy, consumables only: an integer 1–99, default 1. A durable accepts only 1.

opts.idempotencyKey

string

No

1–64 characters of letters, digits, _, ., - or :. A retry with the same key replays the original purchase instead of charging again.

Resolves

Value

Meaning

{ ok: true, purchased: sku, purchaseId, kind, quantity, coins }

Bought. quantity is the total the player now holds (1 for a durable). coins is the balance after the purchase. For a 0-price item nothing is charged.

{ ok: true, alreadyOwned: true, kind: "durable", quantity: 1, coins }

Durable already owned. No charge. coins is the current balance.

{ ok: true, replayed: true, purchased, purchaseId, kind, status, quantity, coins }

The idempotencyKey matched an earlier purchase, which is returned unchanged. Nothing was charged. status is that purchase's state ("completed", or "refunded" if it has since been refunded).

{ error: "sign_in_required" }

The player is a guest. Nothing happened.

{ error: "insufficient_coins" }

The balance is too low for the total. Nothing was charged.

{ error: "bad_quantity" }

A quantity other than 1 for a durable item

{ error: "idempotency_conflict" }

That key was already used for a different SKU

{ error: "not_found" }

No active item with that SKU on this game, or the game wasn't found

{ error: "rate_limited" }

More than 60 purchases per minute by this player

{ error: "validation_error" }

The SKU, quantity or key broke the constraints above

{ error: "unauthorized" }

The player's sign-in is no longer valid

{ error: "error" } or { error: "internal" }

Other failure

null

Timeout (6 s), network error, or sku was an empty string

Events: fires purchase only for a successful answer (ok: true, including alreadyOwned and replayed), with that answer as the payload. Errors, null and the timeout never fire it. A successful answer that arrives after the 6-second timeout has already resolved your promise with null still fires the event, so listening for purchase catches late successes.

Idempotency:

  • A purchase is atomic: the sale is recorded, the entitlement granted, the player charged and the creator credited together, or not at all.

  • Repeat purchases of an owned durable are free no-ops that return alreadyOwned. Two of them in flight at the same time can't both charge: at most one is charged and the other resolves alreadyOwned (or fails on its own, for example with insufficient_coins).

  • Repeat purchases of a consumable are real purchases and charge again. There's no automatic protection, so pass an idempotencyKey whenever a retry is possible.

  • An idempotencyKey is scoped to this player and game. The first call does the work; any repeat returns that same purchase with replayed: true.

Sign-in: required. Guests get { error: "sign_in_required" }.

Limits: 60 purchases per minute per player. 99 units per consumable purchase.

Example

buyBtn.onclick = async function () {
  buyBtn.disabled = true;
  const key = "buy-" + Date.now() + "-" + Math.random().toString(36).slice(2);
  const r = await GameSDK.economy.purchase("extra_life", { quantity: 5, idempotencyKey: key });
  buyBtn.disabled = false;
  if (r && r.ok) { setUnits("extra_life", r.quantity); setBalance(r.coins); return; }
  if (r === null) {                                   // unknown outcome: retry with the SAME key
    const again = await GameSDK.economy.purchase("extra_life", { quantity: 5, idempotencyKey: key });
    if (again && again.ok) setUnits("extra_life", again.quantity);
    return;
  }
  toast(r.error === "insufficient_coins" ? "Not enough coins" :
        r.error === "sign_in_required" ? "Sign in to buy" : "Purchase failed");
};

Pitfalls

  • A null result doesn't mean "not purchased". Retry with the same idempotencyKey, or reconcile with getEntitlements().

  • Without a key, a retried consumable purchase charges twice.

  • Buying a consumable doesn't spend it. Call consume when the player actually uses a unit.

  • A creator can refund a sale within 30 days, which revokes the entitlement or reduces the units. Re-read getEntitlements() on load rather than caching ownership forever.

  • Coins can't be bought. Don't show a "buy coins" prompt.


GameSDK.economy.consume(sku, qty?)

GameSDK.economy.consume(sku: string, qty?: number): Promise<
  { ok: true; sku: string; consumed: number; remaining: number } | { error: string } | null
>

Spends units of a consumable the signed-in player holds. Atomic, and never goes below zero.

Parameters

Name

Type

Required

Constraints

sku

string

Yes

An item on this game with kind: "consumable". It may be inactive: units already bought can still be spent.

qty

number

No

Units to spend. Default 1. The SDK floors it and raises anything below 1 to 1; the API rejects more than 1,000 with validation_error.

Resolves

Value

Meaning

{ ok: true, sku, consumed, remaining }

Spent. remaining is the units left afterwards.

{ error: "insufficient_quantity" }

The player holds fewer than qty. Nothing was spent.

{ error: "not_consumable" }

The SKU is a durable

{ error: "not_found" }

No item with that SKU on this game (including one you deleted)

{ error: "sign_in_required" }

The player is a guest

{ error: "rate_limited" }

More than 60 consume calls per minute by this player

{ error: "error" } or another API error code

Other failure

null

Timeout (6 s) or network error

Sign-in: required.

Limits: 60 per minute per player, counted separately from purchases. 1,000 units per call.

Example

async function useHint() {
  const r = await GameSDK.economy.consume("hint_token", 1);
  if (r && r.ok) { setUnits("hint_token", r.remaining); revealHint(); return true; }
  if (r && r.error === "insufficient_quantity") openStore("hint_token");
  return false;
}

Pitfalls

  • Spend first, then grant the effect. A null result is ambiguous, so don't give the effect away on an answer you didn't get; re-read getEntitlements() instead.

  • insufficient_quantity spends nothing, so it's safe to show the store and retry.


GameSDK.economy.getEntitlements()

GameSDK.economy.getEntitlements(): Promise<{
  owned: string[];
  entitlements?: Array<{ sku: string; kind: "durable" | "consumable"; quantity: number }>;
} | null>

What the signed-in player holds in this game.

Parameters: none.

Resolves: { owned: ["skin_gold", …], entitlements: [{ sku, kind, quantity }, …] }.

  • owned lists the SKUs the player holds right now: durables they own, and consumables with at least one unit left.

  • entitlements gives every SKU they've ever held, with its kind and current units. A durable has quantity: 1; a consumable spent down to 0 stays here with quantity: 0 while dropping out of owned.

  • Both include SKUs whose items have since been deactivated or deleted.

Failure behaviour: resolves { owned: [] } — with no entitlements key — for guests and when the API returns an error, and null on a network error or timeout.

{ owned: [] } doesn't prove the player holds nothing. It's also what you get for a guest or an API error. Don't remove unlocks based on an empty result. Only add unlocks based on owned.

Sign-in: needed for a meaningful result. Guests always get { owned: [] }.

Example

GameSDK.economy.getEntitlements().then(function (e) {
  ((e && e.owned) || []).forEach(unlock);
  ((e && e.entitlements) || []).forEach(function (t) {
    if (t.kind === "consumable") setUnits(t.sku, t.quantity);
  });
});

Tournaments

Tournaments are created server-side by the creator. See Tournaments. Scores reach them automatically through GameSDK.submitScore() and GameSDK.replay.submit(). There's no tournament-specific score call.

The Tournament objects below share this shape:

interface Tournament {
  id: string;
  name: string;
  description: string | null;
  metric: "high_score" | "total_score";
  status: "scheduled" | "live" | "ended" | "settled" | "canceled";
  prizePoolCoins: number;          // coins (virtual currency)
  maxWinners: number;
  startsAt: string;                // ISO 8601
  endsAt: string;                  // ISO 8601
}

GameSDK.tournaments.list()

GameSDK.tournaments.list(): Promise<ListedTournament[] | null>

interface ListedTournament extends Tournament {
  entries: number;          // number of players entered
  joined: boolean;          // this player has an entry (joined or scored)
  myRank: number | null;    // this player's current rank; null until they've scored
}

This game's tournaments:

  • scheduled, live and ended ones

  • settled ones for 7 days after settlement

  • up to 20, latest endsAt first

  • never canceled ones

Status changes (scheduled → live, and settlement of tournaments past endsAt) happen in the background about every 30 seconds. Reading the list also applies any that are due.

Parameters: none.

Resolves: an array (possibly empty).

  • myRank uses the same ranking as the standings and settlement: higher score first, then whoever reached that score first. Players in an exact tie (same score reached at the same moment) share a rank, and the next rank is skipped (1, 2, 2, 4). See Tournaments.

  • joined is true for any entry. A player who joined but hasn't scored has joined: true and myRank: null.

Failure behaviour: resolves [] if the API returns an error, and null on a network error or timeout.

Sign-in: not required. For guests, joined is false and myRank is null.

Example

const ts = (await GameSDK.tournaments.list()) || [];
ts.filter(function (t) { return t.status === "scheduled"; })
  .forEach(function (t) { addUpcoming(t.name, new Date(t.startsAt)); });

myRank always matches the player's rank in getStandings(). Use it when the player is outside the top 100 that getStandings() returns.


GameSDK.tournaments.getActive()

GameSDK.tournaments.getActive(): Promise<ListedTournament | null>

A convenience wrapper over list(). It returns the first tournament with status === "live", which is the live one with the latest endsAt, or null.

Parameters: none.

Resolves: one ListedTournament, or null if none is live.

Failure behaviour: null on any failure or timeout. That's indistinguishable from "no live tournament".

Sign-in: not required. Guest behaviour is the same as for list().

Example

GameSDK.tournaments.getActive().then(function (t) {
  if (t) showBanner(t.name + " ends " + new Date(t.endsAt).toLocaleString());
});

GameSDK.tournaments.join(id)

GameSDK.tournaments.join(id: string): Promise<{ ok: true; joined: true } | null>

Enters the signed-in player into a tournament with a score of 0 and no rank, so they appear on the board before scoring. Joining is optional. The first accepted score enters them anyway.

Parameters

Name

Type

Required

Constraints

id

string

Yes

A tournament id. It must be scheduled or live, with endsAt in the future.

Resolves: { ok: true, joined: true }. That's also the result when the player had already joined.

Failure behaviour: resolves null for any of these:

  • the player is a guest

  • the tournament isn't open (closed)

  • the tournament doesn't exist

  • any other error, or a timeout

The specific reason isn't passed to the game.

Events: fires tournamentJoined with { ok: true, joined: true } only when the join succeeded. Failures and timeouts never fire it. A success that arrives after the 6-second timeout still fires the event.

Sign-in: required. Guests get null.

Example

joinBtn.onclick = async function () {
  const r = await GameSDK.tournaments.join(currentTournament.id);
  if (r && r.joined) joinBtn.textContent = "You're in!";
  else toast("Sign in to join, or this tournament has closed.");
};

Pitfall: joining alone never wins anything. A player who joined but never scored has no rank and gets no prize, however few players there are.


GameSDK.tournaments.getStandings(id)

GameSDK.tournaments.getStandings(id: string): Promise<{
  tournament: Tournament & { game: { slug: string; title: string } };
  standings: Standing[];
} | null>

interface Standing {
  rank: number | null;    // null for an entrant who hasn't scored
  handle: string;
  displayName: string | null;
  avatarUrl: string | null;
  score: number;
  prizeCoins: number;     // 0 until settled
}

The full detail and the top 100 standings of any tournament, by id. Reading it settles the tournament straight away if its window has closed and the background job hasn't settled it yet.

Parameters

Name

Type

Required

Constraints

id

string

Yes

Any tournament id. It doesn't have to belong to this game.

Resolves:

  • Standings are ordered by score, highest first, then by who reached that score first. Entrants who haven't scored come last, with rank: null.

  • rank is a competition rank: players in an exact tie share it and the next rank is skipped (1, 2, 2, 4). It's the same rank myRank in list() reports and settlement uses.

  • Before settlement, prizeCoins is 0. After settlement, rank and prizeCoins are the final stamped values. Tied winners show their equal split of the prize.

Failure behaviour: null if the tournament doesn't exist, on an API error, or on a timeout.

Sign-in: not required. The result is the same for everyone.

Example

const r = await GameSDK.tournaments.getStandings(t.id);
if (r) r.standings.slice(0, 10).forEach(function (s) {
  addRow(s.rank ? "#" + s.rank : "–", s.displayName || s.handle, s.score,
         s.prizeCoins ? s.prizeCoins + " coins" : "");
});

Pitfalls

  • Only the top 100 are returned. A player ranked lower won't find themselves here, so use myRank from list().

  • Two rows can share a rank. Don't use rank as a unique key or list index.


Events

Subscribe with GameSDK.on(name, callback). Callbacks that throw are ignored and don't affect other listeners.

Event

Payload

Fired by

adStarted

none

An ad overlay appeared

adComplete

{ rewarded: boolean, rewardId?: string }

An ad ended or was closed

adError

{ reason: "capped" | "not_configured" | "no_fill" | "no_consent" | "no_session" | "busy" | "timeout" | "preview" | "error", retryAfterSec?: number }

No ad was shown for that request

purchase

The successful purchase() result ({ ok: true, … })

A successful economy.purchase, including alreadyOwned and replayed

tournamentJoined

{ ok: true, joined: true }

A successful tournaments.join

pause / resume

none

The browser tab was hidden or shown. Not fired for ads.

Related

Was this page helpful?