Skip to content
Skip the menu

Browser SDK

Browser SDK

The complete reference of gamecoin.js, the one-file SDK that lets a browser game read, spend, buy, consume, sell packs, redeem gift codes and open the shop with a publishable key.

View as Markdown

On this page

Overview

gamecoin.js is the SDK for the browser. It is one file, with no dependency and no build step (ES2020). It talks to the client API (/api/v1/client/**) with your publishable key.

A browser can read, spend, buy an item with a currency, consume an item, pay for a pack, redeem a gift code and open the shop widget. It can never credit a player by itself: the SDK has no grant, no item grant, no refund. Coins come from a paid order or from your server, with one exception: a gift code that your studio created, which gives free coins and items, once per player, and with a limited number of uses (see redeemCode()). See the Security model.

The SDK runs in two modes:

ModeYou passThe SDK
Anonymous player (a game without a backend)Only publishableKeyCreates an anonymous player on the first visit and remembers it in localStorage
Your player (a game with a backend)publishableKey and playerTokenCreates nothing: it acts as the player of the token

Install

Load the file with a classic script tag, from your GameCoin host. It adds a global GameCoin and takes the host it was loaded from as the place to call.

HTML
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
  const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });

  gc.on("change", ({ wallet, inventory }) => console.log(wallet.balances, inventory));
  console.log(gc.balance("gems"));
</script>

The file is also a CommonJS module (module.exports), for a bundler or a test. There, it cannot know where it was loaded from, so pass baseUrl. Download it and ship it with your game if you prefer not to load it from our host.

JavaScript
const GameCoin = require("./gamecoin.js");
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…", baseUrl: "https://gamecoin.apilow.com" });

GameCoin.version is the version of the file ("1.2.0").

GameCoin.init()

GameCoin.init(options) → Promise<GameCoinClient>

init makes the player ready (it creates or recognizes them), reads their wallet and inventory, and returns the client. Await it before anything else.

OptionTypeDefaultMeaning
publishableKeystringrequiredA gc_pk_… key. A secret key (gc_sk_…) is refused: never put one in a browser.
playerTokenstringnoneA player token issued by your server (POST /players/{player}/tokens). With it, the SDK never creates or stores a player.
onTokenExpiredasync () => stringnoneCalled once when a request is refused because the token expired. Return a new token. See expired tokens.
baseUrlstringthe origin of the <script src>Where GameCoin runs. Required when the SDK was not loaded from a script tag.
storage{ getItem, setItem, removeItem } or nulllocalStorage, or memoryWhere the anonymous player is remembered. null remembers nothing.
displayNamestringnoneThe name given to a new anonymous player
debugbooleanfalseLog requests and retries with console.debug
maxAttemptsnumber3Attempts per request on network errors and 5xx
retryDelayMsnumber400The first wait between attempts, doubled each time
timeoutMsnumber20000The timeout of one request
fetch, windowfunctionsthe globalsInjection points for tests and non-browser environments

init fails with a GameCoinError when the key is missing, is a secret key, is not a publishable key, or when baseUrl is needed and absent. These errors are raised before any request. Anonymous players that are turned off for the game make init fail with FORBIDDEN.

A page that comes back from a redirect checkout also has ?order=…&status=… in its address. init reads that order, removes the parameters from the address, and exposes the order as gc.returnedOrder.

The client

JavaScript
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });

Properties

PropertyTypeMeaning
gc.playerPlayerThe current player: { id, externalId, kind, displayName, country, blocked, createdAt }. A copy: changing it changes nothing.
gc.walletWalletA copy of the cached wallet: { playerId, balances: [{ currency, paid, bonus, total }] }
gc.inventoryInventoryItem[]A copy of the cached inventory: [{ sku, quantity, updatedAt }], owned items only
gc.returnedOrderOrder or nullThe order read when the player came back from a redirect checkout

Methods at a glance

CallReturnsNeeds the server
getCatalog(){ currencies, items, packs, prelaunch, launchAt }yes
getWallet()Walletyes
getInventory()InventoryItem[]yes
refresh(){ wallet, inventory }yes
balance(currency)numberno, cached
owned(sku)numberno, cached
spend(currency, amount, reason?, options?){ entry, balance }yes
buyItem(sku, quantity = 1, options?){ entry, balance, item }yes
consume(sku, quantity = 1, options?){ item }yes
checkout(packSku, options?)Orderyes
redeemCode(code, options?){ grants, items, skipped }yes
openShop(options){ close() }yes
requestEmailCode(email, options?){ sent, expiresInSeconds }yes
verifyEmailCode(email, code, options?){ player, merged, switched }yes
signInWithEmail(email, options?){ sent, expiresInSeconds }yes
verifySignIn(email, code, options?){ player, merged, switched }yes
on(event, handler)an unsubscribe functionno
off(event, handler)nothingno
setPlayerToken(token)nothingno

Every call that talks to GameCoin returns a promise and fails with a GameCoinError. One function does not need a client or the server: GameCoin.packStatus(pack, now?) tells whether a pack of the catalog is scheduled, on sale, sold out or ended, see Pack status.

Reading

getCatalog()

gc.getCatalog() → Promise<{ currencies, items, packs, prelaunch, launchAt }>

The active currencies, items and packs of your game. It needs only the publishable key, so it works before a player exists. Prices of packs are in euro cents, VAT included.

JavaScript
const { currencies, items, packs } = await gc.getCatalog();
console.log(packs.map((p) => `${p.name}: ${(p.priceCents / 100).toFixed(2)} €`)); // [ 'Starter pack: 0.99 €', 'Bag of gems: 4.99 €' ]

The catalog is what the API answers, with one change: the dates are Date objects, not strings. They are the ones of the founder packs and of the launch of your game:

FieldTypeMeaning
pack.availabilityobject or nullnull for a pack that is always on sale, without limit.
pack.availability.startsAt, endsAtDate or nullThe sale window: it starts at startsAt (included) and ends at endsAt (excluded). null is no bound.
pack.availability.remainingnumber or nullThe units that can still be bought, or null for an unlimited stock.
pack.availability.founderbooleantrue for a founder pack.
prelaunchbooleantrue while your game is not launched: show pre-orders.
launchAtDate or nullThe launch date you set in the game settings.

The catalog also lists the packs that are scheduled, ended or sold out: you decide what to show. A date that cannot be read becomes null, never an invalid Date.

Pack status

GameCoin.packStatus(pack, now = new Date()) → "scheduled" | "on_sale" | "sold_out" | "ended"

A pure function: it makes no request and needs no client. It applies the rules of the server to a pack of getCatalog() (or of the raw API answer: ISO strings are accepted). now is a Date, a timestamp in milliseconds or an ISO string; a value that is none of these fails with VALIDATION_FAILED.

ResultWhen
"scheduled"now is before startsAt
"ended"now is at or after endsAt
"sold_out"The window is open and remaining is 0
"on_sale"Everything else, including a pack with availability: null

The window comes first: a pack that has ended is "ended" even when it is also sold out, as on the server.

JavaScript
const catalog = await gc.getCatalog();
for (const pack of catalog.packs) {
  const status = GameCoin.packStatus(pack);
  if (status === "scheduled") showSoon(pack, pack.availability.startsAt);
  else if (status === "on_sale") showBuyButton(pack, pack.availability?.remaining); // "312 left", or null
  else showGreyedOut(pack, status); // "sold_out" or "ended"
}
if (catalog.prelaunch && catalog.launchAt) showBanner("Launch on " + catalog.launchAt.toLocaleDateString());

Note

This is a snapshot, computed with the clock of the player's device. The server decides when the order is created: it answers PACK_NOT_AVAILABLE or PACK_SOLD_OUT to a checkout that comes too early, too late or too slow. remaining can also go up again, when a pending order expires or an order is refunded. Read the catalog again before you rely on it.

gc.getWallet()     → Promise<Wallet>
gc.getInventory()  → Promise<InventoryItem[]>
gc.refresh()       → Promise<{ wallet, inventory }>

Each one reads the server (one request, GET /client/me) and updates the cache. If the wallet or the inventory changed, a change event fires with reason: "refresh". Use refresh() after your server changed something, for example a reward it granted.

JavaScript
await fetch("/api/level-complete", { method: "POST" }); // your server grants gems
await gc.refresh();
console.log(gc.balance("gems"));
gc.balance(currency) → number
gc.owned(sku)        → number

Synchronous reads of the cache: balance is the total of a currency, owned is the quantity of an item. Both return 0 for a code the SDK does not know. They make no request, so call them as often as you draw the screen.

Changing

The calls below take a last argument options. Its main key is idempotencyKey: see idempotency.

spend()

gc.spend(currency, amount, reason?, options?) → Promise<{ entry, balance }>

Spends currency with no item: "continue?", a skip, an entry fee.

ArgumentMeaning
currencyThe code of the currency, for example "gems"
amountA positive whole number
reasonFree text kept in the ledger (up to 500 characters). Optional.
JavaScript
const { entry, balance } = await gc.spend("gems", 5, "continue");
console.log(entry.type, balance.total); // spend 95
ErrorCause
INSUFFICIENT_FUNDSThe wallet is too low. details has required and available.
NOT_FOUNDUnknown currency
VALIDATION_FAILEDamount is not a positive whole number (raised before any request)
PLAYER_BLOCKEDThe player is blocked

buyItem()

gc.buyItem(sku, quantity = 1, options?) → Promise<{ entry, balance, item }>

Buys an item with its own price, in its own currency. The wallet is debited and the item added in one step. Only items marked « Achetable depuis le jeu (API cliente) » in the dashboard can be bought from the browser.

JavaScript
const { balance, item } = await gc.buyItem("potion", 2);
console.log(balance.total, item.quantity); // 55 2
ErrorCause
INSUFFICIENT_FUNDSThe player cannot afford it
LIMIT_REACHEDThe item's maxOwned would be exceeded. details has maxOwned and owned.
FORBIDDENThe item is not buyable from the game
NOT_FOUNDUnknown item

consume()

gc.consume(sku, quantity = 1, options?) → Promise<{ item }>

Uses up units of a consumable item. The returned item is the item as it now stands, even when it reaches quantity: 0.

JavaScript
const { item } = await gc.consume("potion");
console.log(item.quantity); // 1
ErrorCause
INSUFFICIENT_FUNDSThe player owns fewer than quantity. details has required and available.
CONFLICTThe item is durable: it cannot be consumed
NOT_FOUNDUnknown item

redeemCode()

gc.redeemCode(code, options?) → Promise<{ grants, items, skipped }>

Redeems a gift code that your studio created, for the current player. It is the only call of the browser SDK that adds coins, and it is safe because the value is decided by your studio, not by the browser: the code is random, it has a limited number of uses, a player can use it once, and it gives free coins (never paid ones) and items. See Codes API.

JavaScript
try {
  const { grants, items, skipped } = await gc.redeemCode(input.value);
  showMessage(`You received ${grants.map((g) => g.amount + " " + g.currency).join(", ")}!`);
} catch (error) {
  if (!(error instanceof GameCoin.GameCoinError)) throw error;
  if (error.details.fieldErrors?.code) showMessage("This code is not valid.");
  else if (error.code === "LIMIT_REACHED") showMessage("You already have this reward.");
  else if (error.code === "RATE_LIMITED") showMessage("Too many tries. Wait a little.");
  else throw error;
}
WhatMeaning
codeThe code as the player typed it, a non-empty string. The SDK does not trim it or change its case: the API ignores case, spaces and dashes. Anything else fails at once with VALIDATION_FAILED (status: 0, no request).
grantsOne line per currency credited: { currency, amount, balance }, where balance is the balance afterwards. Always free coins.
itemsWhat was added to the inventory: [{ sku, quantity }]
skippedThe items the player could not receive because they already own the most they can: [{ sku, quantity }]. The rest is still given.

The cached balances take the value of the answer, the cached quantities of the items go up by what was added, and a change event fires with reason: "code". If you need the exact inventory after a long time, call refresh().

The call is idempotent like the others: it sends a key (yours, or a random one), replays on a network error with the same key, and a replay credits nothing twice. A code the player already redeemed is a refusal like any other: typing it again, with a new key, gives CODE_INVALID.

ErrorCause
VALIDATION_FAILEDdetails.fieldErrors.code is ["CODE_INVALID"]: the code is unknown, malformed, expired, used up, disabled, already used by this player, or a creator code. This is the API's own error, passed on as it is: the SDK never replays it nor rewords it, and nothing tells which of these it is. Show one message.
LIMIT_REACHEDThe player already owns the most of every item of the code, and it gives nothing else. The code is not used up.
PLAYER_BLOCKEDThe player is blocked
RATE_LIMITED10 tries per 15 minutes per player, right or wrong. details.retryAfter gives the seconds.

With a playerToken from your backend, the call works too: the token is the player's, so the code is redeemed for them and the cache follows. If your backend must decide who may redeem (a login, a quota of your own), do not offer the code field in the browser: call the server route from your server.

checkout()

gc.checkout(packSku, options?) → Promise<Order>

Sells a pack for euros. It opens the hosted payment page and resolves when the order is over. Call it straight from a click: the SDK opens the payment window synchronously, before any request, and browsers block windows that are not opened by a user action.

JavaScript
document.getElementById("buy").onclick = async () => {
  const order = await gc.checkout("gems-500");
  console.log(order.status); // "fulfilled", "failed" or "pending"
};
OptionMeaning
mode"redirect" sends the current tab to the payment page instead of opening a window
successUrl, cancelUrlWhere the player returns after paying or declining. They must have the origin of the page. By default, the current address.
idempotencyKeyYour own idempotency key
locale"fr", "en" or "nl": the language of the payment page, the receipt and the e-mails of the order. By default, the language of the page, see Language.
creatorCodeThe creator code the buyer typed, for example "LEAPLAY". See Creator code.

What it resolves with, in a window:

order.statusMeaning
fulfilledThe player paid. The pack is delivered and the cached wallet is already refreshed.
failedThe payment was declined
pendingThe player closed the window without paying
expired and othersThe order ended another way. Read order.status.

The SDK listens to the window's postMessage (only from the GameCoin origin) and reads the order from the API every 2 seconds. A message is only a signal: the status always comes from the API, never from the message.

Redirect. When the browser blocks the window, or with mode: "redirect", the current tab goes to the payment page and returns to the current address. The promise then resolves at once with the pending order plus redirected: true, since the page is about to leave. On the way back, init() reads the order, removes ?order=&status= from the address, exposes it as gc.returnedOrder, and fires a change event with reason: "return" and the order.

JavaScript
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
gc.on("change", ({ reason, order }) => {
  if (reason === "return") console.log("back from payment:", order.status);
});
if (gc.returnedOrder) console.log(gc.returnedOrder.status);

Only one checkout can run at a time: a second call while the first runs fails with CONFLICT.

ErrorCause
NOT_FOUNDUnknown pack, or a pack that is still a draft
LIMIT_REACHEDThe pack's maxPerPlayer is reached
PAYMENT_FAILEDThere is no payment provider for this environment
CONFLICTA checkout is already in progress
PACK_NOT_AVAILABLEThe pack is outside its sale window. details.startsAt and details.endsAt hold the dates (ISO 8601 strings, as the API sends them, or null). See Pack status.
PACK_SOLD_OUTThe pack has no unit left
VALIDATION_FAILEDpackSku is empty, a return URL does not have the origin of the page, creatorCode is not a non-empty string, or the creator code is refused (see below)

See Selling packs for VAT, return URLs and the life of an order.

Creator code

A buyer who has a creator code (a streamer's code) types it at checkout. Pass it as it was typed:

JavaScript
const order = await gc.checkout("gems-500", { creatorCode: input.value || undefined });
console.log(order.creatorCode); // { code: "LEAPLAY", creatorName: "Léa Play" }, or null without a code
  • It goes in the field creatorCode of the order. The SDK does not change it (the API ignores the case). Anything but a non-empty string fails at once with VALIDATION_FAILED, before any window or request: pass undefined when the buyer typed nothing.
  • The Order that checkout() resolves with (and gc.returnedOrder) has creatorCode: { code, creatorName }, or null. It never carries the rates of the creator.
  • A code that cannot be used (unknown, disabled, not valid for this pack, or the creator's own player) is refused by the API with VALIDATION_FAILED and details.fieldErrors.creatorCode equal to ["CODE_INVALID"], one answer for every cause. The SDK passes that error on as it is, never replays it, and closes the payment window it had opened: no order was created. Tell the player the code is not valid, and let them try again or buy without it.
  • The bonus coins that the code gives are added by GameCoin when the order is delivered, with the pack, so the wallet that checkout() refreshes already has them.

Open the shop

gc.openShop(options) → Promise<{ close() }>

Opens the shop widget over your game, as the player of this SDK. The SDK holds the player token, so you do not have to make one, keep one or pass one around: that is the way to use the widget in a game without a server.

JavaScript
document.getElementById("shop").onclick = async () => {
  const shop = await gc.openShop({ gameSlug: "my-game", locale: "en", theme: "dark" });
  // later, if you need to: shop.close();
};

gc.on("change", ({ wallet, reason }) => {
  if (reason === "shop") draw(wallet); // the player bought something, or typed a code, in the shop
});
OptionTypeDefaultMeaning
gameSlugstringrequiredThe slug of your game (lowercase letters, digits and dashes). The shop must be turned on, and the origin of your page must be in the allowed origins of the game.
locale"fr", "en" or "nl"The language of the pageThe language of the shop
theme"light" or "dark""light"The colors of the shop
titlestring« Shop » in the languageThe accessible name of the dialog
loadTimeoutMsnumber15000How long to wait for the shop before it offers « Try again »

Wrong options fail at once with VALIDATION_FAILED (status: 0), before anything is loaded. The other options of the widget (baseUrl, token) are not options here: the SDK uses its own base and its own token.

What the SDK does, in this order:

  1. Loads the widget once. If window.GameCoinShop does not exist, it adds one <script src> for gamecoin-widget.js, from the same place as the SDK (<baseUrl>/sdk/gamecoin-widget.js), and waits for it. It never uses eval. The script is added once per page, however many times you call openShop(). If it cannot be loaded (network, or a Content-Security-Policy whose script-src does not allow your GameCoin host), the call fails with NETWORK_ERROR, and the next call tries again.
  2. Makes sure the token is fresh. A token that has less than 5 minutes left is renewed first, the way the SDK renews it after a 401. See The token of a backend game for your own tokens.
  3. Opens the shop with GameCoinShop.open, and gives it the token.

Note

The token never reaches your code. The SDK hands it to the widget and to nobody else: it is not in the value that openShop() returns, in an event, in an error message or in the debug log. Call openShop() from a click or a tap.

What the shop tells your game

The events of the widget are relayed on the client, with gc.on(event, handler):

EventPayloadWhen
"shop:purchased"{ orderId, status }An order of this player ended in the shop: fulfilled, failed, canceled, expired… The SDK has already read the wallet again, so gc.balance() is up to date when your handler runs.
"shop:close"{}The shop was closed: button, Escape, a click on the backdrop, close(), or another openShop().
"shop:error"{ code, message }SESSION_EXPIRED (the token was refused: call openShop() again) or LOAD_FAILED (the shop did not answer in time: wrong game slug, shop turned off, origin not allowed).

Before "shop:purchased", the SDK reads the player (GET /client/me) and, if the wallet or the inventory changed, fires change with reason: "shop". It does the same when the shop shows a balance that is not the one in the cache, which is what happens when the player types a gift code in the shop. The balance that the widget announces is only a signal: the numbers always come from the API.

Warning

"shop:purchased" comes from a browser, so anyone can send it. For anything your game gives because of a purchase (a reward, an unlock), read the order on your server (GET /orders/{order}, or the order.fulfilled webhook) first. The coins and the items of the pack are credited by GameCoin itself, whatever the event says. Events stop when the shop closes: if the player pays in the payment window and closes the shop at once, call refresh().

Only one shop is open at a time: openShop() while another is open closes it first ("shop:close" fires once for it), and the handle of the old one no longer closes anything. A second openShop() while the first is still opening fails with CONFLICT.

The token of a backend game

With a playerToken from your server, openShop() opens the shop as that player, with that token: nothing is created and nothing is refused. The SDK cannot read the end of a token that your server made, so it first reads the player (one request). If the token is refused, it goes through onTokenExpired like any other call, and the shop opens with the new token. Without onTokenExpired, the call fails with UNAUTHENTICATED and the shop does not open. A token that is accepted but about to end is not renewed: the shop swaps it for its own session of one hour as soon as it loads. Pass onTokenExpired, as for every backend game.

Language

Three calls send e-mails or show a page to the player: checkout() (payment page, receipt and e-mails of the order), requestEmailCode() and signInWithEmail() (the e-mail with the code). They accept the same locale option, "fr", "en" or "nl":

JavaScript
await gc.checkout("gems-500", { locale: "nl" });
await gc.requestEmailCode("alice@example.com", { locale: "en" });

Without it, the SDK uses the language of your page, the lang attribute of <html>, when it is one of the three ("fr-BE" counts as "fr"). For any other language, or none, it sends nothing and the server follows the browser's Accept-Language, then French. A locale that is not one of the three is not sent either, because the API ignores it. A locale that is not a string fails with VALIDATION_FAILED, before any request or any window.

E-mail sign-in

An anonymous player lives in the browser's storage. With an e-mail address and a six-digit code (no password) they can find the same player, with their coins and their items, on another device or after clearing the storage. The Sign in players with their e-mail guide explains the idea and the routes; this section is the SDK.

The four methods are for anonymous players. With a playerToken from your backend they fail at once with FORBIDDEN (status: 0, no request): in that mode your backend owns the identity of the player. All four are idempotent like the other calls, and take a last options argument with idempotencyKey, and with locale for the two that send a code.

You are in this situationSend the codeCheck it
The player is playing and wants to keep their progress, or move to the player they already linkedrequestEmailCode(email, options?)verifyEmailCode(email, code, options?)
The player wants to get back the player of an address (new device, empty storage)signInWithEmail(email, options?)verifySignIn(email, code, options?)
JavaScript
// 1. The player types their address; GameCoin e-mails a 6-digit code.
await gc.requestEmailCode("alice@example.com", { locale: "en" }); // { sent: true, expiresInSeconds: 600 }

// 2. The player types the code. Keep it a string: "042917" starts with a zero.
try {
  const { player, merged, switched } = await gc.verifyEmailCode("alice@example.com", "042917");
  if (switched) console.log("welcome back, you are now player", player.id);
} catch (error) {
  if (error instanceof GameCoin.GameCoinError && error.details.fieldErrors?.code) showMessage("That code is not valid. Ask for a new one.");
  else throw error;
}

Sending a code always answers { sent: true, expiresInSeconds }, whether the address is known or not.

What the check returns and keeps

verifyEmailCode and verifySignIn resolve with { player, merged, switched }:

FieldMeaning
playerThe player the session now belongs to (a copy)
mergedtrue when the address was already linked to another player and the session switched to it (verifyEmailCode only)
switchedtrue when the player is not the one this device had before the call. This is the flag to read: it is true after every verifySignIn that finds another player.

The new token and the new playerSecret that the API returns never reach your code: the SDK keeps them, the way it keeps the token and the secret of an anonymous player. The token replaces the current one. A new secret replaces the remembered { playerId, playerSecret } in the storage, so the next visits and every token renewal act as the player you got back. If the player changed, the SDK reads the wallet and the inventory of the new player and fires change with reason: "email" (after verifyEmailCode) or "sign-in" (after verifySignIn), even when the numbers are the same: gc.player is not the same player any more. If that read fails, the cache is emptied rather than left showing the previous player's coins, and the next refresh() fills it again.

Warning

GameCoin never merges two wallets. When the session switches to another player, the player that this device had before is left as it was on the server, with its coins and its items, but this device forgets its secret: nothing here can reach it any more. Tell the player before they switch.

Wrong codes

A wrong, expired, used up, already used or never requested code always gives the same error, so that nobody learns anything from it: a GameCoinError with code: "VALIDATION_FAILED", status: 400 and details.fieldErrors.code equal to ["CODE_INVALID"]. The session and the storage do not change. Five wrong tries kill a code: ask for a new one with requestEmailCode or signInWithEmail. code must be a string: a number fails locally with VALIDATION_FAILED, because 42917 is not "042917". Spaces and dashes in the string are ignored by the API.

ErrorCause
VALIDATION_FAILEDInvalid address, or details.fieldErrors.code = ["CODE_INVALID"] for a code that is not accepted
NOT_FOUNDverifySignIn with a valid code, but no player is linked to that address
FORBIDDENThe player is not anonymous (a playerToken was given)
PLAYER_BLOCKEDThe player that the address belongs to is blocked
RATE_LIMITEDToo many codes asked. details.retryAfter gives the seconds when the wait is over 10 seconds.

Events

change

The SDK keeps a copy of the wallet and the inventory. It fires change whenever that copy actually changes.

gc.on("change", handler) → unsubscribe function
gc.off("change", handler)

handler receives { wallet, inventory, reason, order? }. wallet and inventory are copies. reason says what caused the change:

reasonFired after
"spend"spend()
"purchase"buyItem()
"consume"consume()
"checkout"A fulfilled checkout() refreshed the wallet
"code"redeemCode() credited a gift code
"shop"The shop widget opened by openShop() reported a purchase or a new balance, and the SDK read the wallet again
"refresh"refresh(), getWallet() or getInventory() found something different
"return"The player came back from a redirect checkout (with order)
"email"verifyEmailCode() switched the session to another player
"sign-in"verifySignIn() switched the session to another player

An error in your handler does not break the SDK or the other handlers. It is reported as an uncaught error. A call that fails changes nothing and fires nothing. on returns a function that removes the handler:

JavaScript
const stop = gc.on("change", ({ wallet }) => draw(wallet));
// later
stop();

Shop events

gc.on also takes "shop:purchased", "shop:close" and "shop:error", the events of the shop opened by openShop(). They have no wallet in their payload: read it with gc.wallet, or wait for change. Any other event name fails with VALIDATION_FAILED.

Idempotency and retries

Every call that changes something (spend, buyItem, consume, checkout, redeemCode and the e-mail calls) sends an idempotency key. By default the SDK makes a random one per call and reuses it for every automatic replay of that call. A retry therefore cannot count twice.

To make a call safe across a page reload, give your own key as the last argument:

JavaScript
await gc.spend("gems", 5, "level-3-entry", { idempotencyKey: "level-3-entry-2026-10-05" });

The SDK replays a request, with the same key, in these cases:

SituationWhat the SDK does
Network error, or a 5xxWaits retryDelayMs, doubled each time, and tries again, up to maxAttempts (3). Then raises NETWORK_ERROR, or the error of the API.
429 RATE_LIMITEDWaits for Retry-After and tries again, if the wait is 10 seconds or less. For a longer wait it raises RATE_LIMITED with the seconds in error.details.retryAfter.
401 (token refused)Renews the token once and replays. See expired tokens.
Any other 4xxRaises the error, never replays

Each attempt has a timeout of timeoutMs (20 seconds by default).

Expired tokens

A player token lasts one hour. When a request is refused with 401:

  • Anonymous player. The SDK exchanges the stored secret for a new token by itself, then replays the request with the same idempotency key. You do nothing.
  • Your player (playerToken). The SDK calls onTokenExpired once. Return a new token from your server, and the SDK replays the request.
JavaScript
const getToken = async () => (await fetch("/api/gamecoin-token", { method: "POST" })).json().then((r) => r.token);

const gc = await GameCoin.init({
  publishableKey: "gc_pk_test_…",
  playerToken: await getToken(),
  onTokenExpired: getToken,
});

Without onTokenExpired, a refused token is raised as UNAUTHENTICATED (and the SDK never creates a player in its place). If the new token is refused too, the SDK stops: it does not loop. Several requests that fail together cause a single renewal.

To swap the token yourself, for example after the player logged in again, use gc.setPlayerToken(token).

Errors

Every failure is a GameCoinError, available as GameCoin.GameCoinError for instanceof.

FieldContent
name"GameCoinError"
codeAn API code (INSUFFICIENT_FUNDS, LIMIT_REACHED, PLAYER_BLOCKED, UNAUTHENTICATED, RATE_LIMITED…) or NETWORK_ERROR
statusThe HTTP status, 0 when no answer was received or when the SDK refused the call before any request
messageA sentence for developers. Do not show it to players.
detailsThe details of the API (required and available for INSUFFICIENT_FUNDS), {} otherwise
JavaScript
try {
  await gc.buyItem("fire-sword");
} catch (error) {
  if (!(error instanceof GameCoin.GameCoinError)) throw error;
  switch (error.code) {
    case "INSUFFICIENT_FUNDS":
      showShop(error.details.required - error.details.available);
      break;
    case "LIMIT_REACHED":
      showMessage("You already own it.");
      break;
    case "NETWORK_ERROR":
      showMessage("No connection. Try again.");
      break;
    default:
      throw error;
  }
}

Arguments the SDK can judge itself (an empty sku, an amount of 0 or 1.5) fail with VALIDATION_FAILED and status: 0, with no request. A NETWORK_ERROR after the last attempt means the write may or may not have happened: read the wallet with refresh(), or pass your own idempotencyKey so that a retry cannot count twice. All codes are in Errors.

The player and the browser's storage

On an anonymous player's first visit, the SDK creates the player and keeps { playerId, playerSecret } in the browser's storage, under a name that includes your publishable key. The next visits use that secret to get a token. Two different publishable keys give two different players in the same browser.

  • If the stored player is refused (401), the SDK creates a new one. Any other error creates nothing.
  • If localStorage is not available (private mode, blocked data, a sandboxed frame), the SDK uses memory: the game works, and the player is new on the next visit.
  • storage: null keeps nothing at all.
  • To use your own store (an engine's save system), pass { getItem, setItem, removeItem }.

The secret never leaves that browser, and it cannot be recovered. Clearing the data makes a new player, unless the player linked an e-mail address: they can then get the same player back with signInWithEmail().

Browsers and limits

  • ES2020, fetch and AbortController. Every modern browser has them.
  • crypto.randomUUID() only exists on secure pages (https:, localhost); elsewhere the SDK makes its keys from random bytes.
  • The client API answers only the origins listed in the game's settings (any origin in test while the list is empty). The SDK never sends cookies.
  • A page served from file:// has no real origin: pass baseUrl. For anything that involves a payment, serve the game over http://localhost or https://.

Where next