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.
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:
| Mode | You pass | The SDK |
|---|---|---|
| Anonymous player (a game without a backend) | Only publishableKey | Creates an anonymous player on the first visit and remembers it in localStorage |
| Your player (a game with a backend) | publishableKey and playerToken | Creates 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.
<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.
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.
| Option | Type | Default | Meaning |
|---|---|---|---|
publishableKey | string | required | A gc_pk_… key. A secret key (gc_sk_…) is refused: never put one in a browser. |
playerToken | string | none | A player token issued by your server (POST /players/{player}/tokens). With it, the SDK never creates or stores a player. |
onTokenExpired | async () => string | none | Called once when a request is refused because the token expired. Return a new token. See expired tokens. |
baseUrl | string | the origin of the <script src> | Where GameCoin runs. Required when the SDK was not loaded from a script tag. |
storage | { getItem, setItem, removeItem } or null | localStorage, or memory | Where the anonymous player is remembered. null remembers nothing. |
displayName | string | none | The name given to a new anonymous player |
debug | boolean | false | Log requests and retries with console.debug |
maxAttempts | number | 3 | Attempts per request on network errors and 5xx |
retryDelayMs | number | 400 | The first wait between attempts, doubled each time |
timeoutMs | number | 20000 | The timeout of one request |
fetch, window | functions | the globals | Injection 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
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });Properties
| Property | Type | Meaning |
|---|---|---|
gc.player | Player | The current player: { id, externalId, kind, displayName, country, blocked, createdAt }. A copy: changing it changes nothing. |
gc.wallet | Wallet | A copy of the cached wallet: { playerId, balances: [{ currency, paid, bonus, total }] } |
gc.inventory | InventoryItem[] | A copy of the cached inventory: [{ sku, quantity, updatedAt }], owned items only |
gc.returnedOrder | Order or null | The order read when the player came back from a redirect checkout |
Methods at a glance
| Call | Returns | Needs the server |
|---|---|---|
getCatalog() | { currencies, items, packs, prelaunch, launchAt } | yes |
getWallet() | Wallet | yes |
getInventory() | InventoryItem[] | yes |
refresh() | { wallet, inventory } | yes |
balance(currency) | number | no, cached |
owned(sku) | number | no, 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?) | Order | yes |
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 function | no |
off(event, handler) | nothing | no |
setPlayerToken(token) | nothing | no |
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.
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:
| Field | Type | Meaning |
|---|---|---|
pack.availability | object or null | null for a pack that is always on sale, without limit. |
pack.availability.startsAt, endsAt | Date or null | The sale window: it starts at startsAt (included) and ends at endsAt (excluded). null is no bound. |
pack.availability.remaining | number or null | The units that can still be bought, or null for an unlimited stock. |
pack.availability.founder | boolean | true for a founder pack. |
prelaunch | boolean | true while your game is not launched: show pre-orders. |
launchAt | Date or null | The 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.
| Result | When |
|---|---|
"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.
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.
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) → numberSynchronous 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.
| Argument | Meaning |
|---|---|
currency | The code of the currency, for example "gems" |
amount | A positive whole number |
reason | Free text kept in the ledger (up to 500 characters). Optional. |
const { entry, balance } = await gc.spend("gems", 5, "continue");
console.log(entry.type, balance.total); // spend 95| Error | Cause |
|---|---|
INSUFFICIENT_FUNDS | The wallet is too low. details has required and available. |
NOT_FOUND | Unknown currency |
VALIDATION_FAILED | amount is not a positive whole number (raised before any request) |
PLAYER_BLOCKED | The 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.
const { balance, item } = await gc.buyItem("potion", 2);
console.log(balance.total, item.quantity); // 55 2| Error | Cause |
|---|---|
INSUFFICIENT_FUNDS | The player cannot afford it |
LIMIT_REACHED | The item's maxOwned would be exceeded. details has maxOwned and owned. |
FORBIDDEN | The item is not buyable from the game |
NOT_FOUND | Unknown 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.
const { item } = await gc.consume("potion");
console.log(item.quantity); // 1| Error | Cause |
|---|---|
INSUFFICIENT_FUNDS | The player owns fewer than quantity. details has required and available. |
CONFLICT | The item is durable: it cannot be consumed |
NOT_FOUND | Unknown 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.
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;
}| What | Meaning |
|---|---|
code | The 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). |
grants | One line per currency credited: { currency, amount, balance }, where balance is the balance afterwards. Always free coins. |
items | What was added to the inventory: [{ sku, quantity }] |
skipped | The 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.
| Error | Cause |
|---|---|
VALIDATION_FAILED | details.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_REACHED | The player already owns the most of every item of the code, and it gives nothing else. The code is not used up. |
PLAYER_BLOCKED | The player is blocked |
RATE_LIMITED | 10 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.
document.getElementById("buy").onclick = async () => {
const order = await gc.checkout("gems-500");
console.log(order.status); // "fulfilled", "failed" or "pending"
};| Option | Meaning |
|---|---|
mode | "redirect" sends the current tab to the payment page instead of opening a window |
successUrl, cancelUrl | Where the player returns after paying or declining. They must have the origin of the page. By default, the current address. |
idempotencyKey | Your 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. |
creatorCode | The creator code the buyer typed, for example "LEAPLAY". See Creator code. |
What it resolves with, in a window:
order.status | Meaning |
|---|---|
fulfilled | The player paid. The pack is delivered and the cached wallet is already refreshed. |
failed | The payment was declined |
pending | The player closed the window without paying |
expired and others | The 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.
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.
| Error | Cause |
|---|---|
NOT_FOUND | Unknown pack, or a pack that is still a draft |
LIMIT_REACHED | The pack's maxPerPlayer is reached |
PAYMENT_FAILED | There is no payment provider for this environment |
CONFLICT | A checkout is already in progress |
PACK_NOT_AVAILABLE | The 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_OUT | The pack has no unit left |
VALIDATION_FAILED | packSku 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:
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
creatorCodeof the order. The SDK does not change it (the API ignores the case). Anything but a non-empty string fails at once withVALIDATION_FAILED, before any window or request: passundefinedwhen the buyer typed nothing. - The
Orderthatcheckout()resolves with (andgc.returnedOrder) hascreatorCode:{ code, creatorName }, ornull. 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_FAILEDanddetails.fieldErrors.creatorCodeequal 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.
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
});| Option | Type | Default | Meaning |
|---|---|---|---|
gameSlug | string | required | The 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 page | The language of the shop |
theme | "light" or "dark" | "light" | The colors of the shop |
title | string | « Shop » in the language | The accessible name of the dialog |
loadTimeoutMs | number | 15000 | How 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:
- Loads the widget once. If
window.GameCoinShopdoes not exist, it adds one<script src>forgamecoin-widget.js, from the same place as the SDK (<baseUrl>/sdk/gamecoin-widget.js), and waits for it. It never useseval. The script is added once per page, however many times you callopenShop(). If it cannot be loaded (network, or a Content-Security-Policy whosescript-srcdoes not allow your GameCoin host), the call fails withNETWORK_ERROR, and the next call tries again. - 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. - 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):
| Event | Payload | When |
|---|---|---|
"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":
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 situation | Send the code | Check it |
|---|---|---|
| The player is playing and wants to keep their progress, or move to the player they already linked | requestEmailCode(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?) |
// 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 }:
| Field | Meaning |
|---|---|
player | The player the session now belongs to (a copy) |
merged | true when the address was already linked to another player and the session switched to it (verifyEmailCode only) |
switched | true 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.
| Error | Cause |
|---|---|
VALIDATION_FAILED | Invalid address, or details.fieldErrors.code = ["CODE_INVALID"] for a code that is not accepted |
NOT_FOUND | verifySignIn with a valid code, but no player is linked to that address |
FORBIDDEN | The player is not anonymous (a playerToken was given) |
PLAYER_BLOCKED | The player that the address belongs to is blocked |
RATE_LIMITED | Too 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:
reason | Fired 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:
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:
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:
| Situation | What the SDK does |
|---|---|
Network error, or a 5xx | Waits retryDelayMs, doubled each time, and tries again, up to maxAttempts (3). Then raises NETWORK_ERROR, or the error of the API. |
429 RATE_LIMITED | Waits 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 4xx | Raises 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 callsonTokenExpiredonce. Return a new token from your server, and the SDK replays the request.
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.
| Field | Content |
|---|---|
name | "GameCoinError" |
code | An API code (INSUFFICIENT_FUNDS, LIMIT_REACHED, PLAYER_BLOCKED, UNAUTHENTICATED, RATE_LIMITED…) or NETWORK_ERROR |
status | The HTTP status, 0 when no answer was received or when the SDK refused the call before any request |
message | A sentence for developers. Do not show it to players. |
details | The details of the API (required and available for INSUFFICIENT_FUNDS), {} otherwise |
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
localStorageis 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: nullkeeps 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,
fetchandAbortController. 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
testwhile the list is empty). The SDK never sends cookies. - A page served from
file://has no real origin: passbaseUrl. For anything that involves a payment, serve the game overhttp://localhostorhttps://.
Where next
- A game without a backend: the SDK from first line to a working page.
- A game with a backend: the SDK with a player token.
- Selling packs
- Gift codes and creator codes and Founder packs
- Shop widget: the shop inside your web game.