Getting started
Quickstart
Sell your game's virtual currency and items in minutes with the browser SDK or the server API, in two paths of three steps each.
On this page
Before you start
GameCoin keeps the ledger, the inventory and the orders of your game. You call it from the browser, from your server, or both. You need a game, a currency and a pack, and your keys.
- Create an account and a game in the dashboard (« Nouveau jeu »). Your test keys are created with the game.
- Create a paid currency, for example
gems(« Monnaies », type « Payante »), then a pack that sells it, for examplegems-500: 500 gems and 50 free, 4.99 € (« Packs »). A new pack is a draft: use « Publier ». For items, create one with a price and tick « Achetable depuis le jeu (API cliente) », so that the browser may buy it. - Copy your keys from the game's overview or from « Clés d'API »: the publishable key
gc_pk_test_…(safe in a browser) and the secret keygc_sk_test_…(shown once, kept on your server).
The dashboard is in French for now; the labels above are the ones you will see.
Pick the path that matches your game. You can start with A and move to B later: both end with the same SDK calls.
| Path | For | How it works |
|---|---|---|
| A. No backend | A browser-only game: itch.io, a game jam, a no-code engine | GameCoin creates an anonymous player and remembers it |
| B. With a backend | A game whose server knows its players | Your server declares them, credits and spends, and gives the browser a short-lived token |
Path A: no backend
Everything runs in the browser, with your publishable key. Three steps.
Step 1: Load the SDK
One file, no dependency, no build step. It adds a global GameCoin and talks to the host it was loaded from.
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>Step 2: Start it
GameCoin.init creates an anonymous player on the first visit and remembers it in localStorage. On the next visits it asks for a fresh token with the remembered secret. Same browser, same player, same balance.
<script type="module">
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
console.log(gc.player.id); // the anonymous player, remembered in localStorage
console.log(gc.balance("gems")); // 0 until the player buys something
const catalog = await gc.getCatalog(); // { currencies, items, packs }
</script>Clearing the browser's data makes a new player. Anonymous players can be turned off in the game's settings (« Réglages »).
Step 3: Sell a pack and an item
A complete page. checkout pays for a pack, buyItem pays for an item with the game's currency, and consume uses one up. The balance and the inventory in the SDK are updated after every call.
<button id="buy-pack">Buy 500 gems</button>
<button id="buy-potion">Buy a potion (20 gems)</button>
<button id="use-potion">Drink a potion</button>
<p>Gems: <span id="gems">0</span></p>
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
// "change" fires after every purchase, spend, consume or refresh.
const render = () => (document.getElementById("gems").textContent = gc.balance("gems"));
gc.on("change", render);
render();
// A pack is paid in euros (simulated in test): checkout() opens the payment page in a window.
// Call it straight from a click, or the browser blocks the window.
document.getElementById("buy-pack").onclick = async () => {
const order = await gc.checkout("gems-500");
console.log(order.status); // "fulfilled" | "failed" | "pending" (window closed before paying)
};
// An item is paid with the game's currency: no window.
document.getElementById("buy-potion").onclick = () => gc.buyItem("potion", 1).catch(console.error);
document.getElementById("use-potion").onclick = () => gc.consume("potion", 1).catch(console.error);
</script>Spending without an item ("continue?", "skip the timer", an entry fee):
await gc.spend("gems", 5, "continue");Note
checkout() opens the payment window synchronously and resolves when the order is over: fulfilled (the coins are in, gc.balance() is already up to date), failed (declined), or pending (the window was closed without paying). If the browser blocks the window, the SDK sends the current tab to the payment page and brings the player back to the same URL. init then reads the order, cleans ?order=&status= from the address and exposes it as gc.returnedOrder. Force it with gc.checkout(sku, { mode: "redirect" }).
Warning
A player can never credit themselves. The SDK has no grant: coins come from a paid order or from your server (path B). The one exception is a gift code that you created in the dashboard: gc.redeemCode("…") gives free coins and items, once per player, and the value is decided by you, not by the browser. See Gift codes and creator codes. Points your game computes locally are yours to keep locally.
The calls of the SDK:
| Call | Returns | Notes |
|---|---|---|
GameCoin.init(options) | the client | Options: publishableKey (required), playerToken, onTokenExpired, baseUrl, storage, displayName, debug. |
gc.getCatalog() | { currencies, items, packs } | Active entries only. |
gc.getWallet() | { playerId, balances } | Reads the server. A fresh copy is also in gc.wallet. |
gc.getInventory() | [{ sku, quantity, updatedAt }] | Owned items only. |
gc.balance(currency) | a number | Cached total; 0 when unknown. Synchronous. |
gc.spend(currency, amount, reason?) | { entry, balance } | Fails with INSUFFICIENT_FUNDS when the wallet is too low. |
gc.buyItem(sku, quantity = 1) | { entry, balance, item } | Only items marked as buyable from the game. |
gc.consume(sku, quantity = 1) | { item } | The item is returned even when it reaches 0. |
gc.checkout(packSku, options?) | the order | From a click. options.mode: "redirect". |
gc.on("change", fn) | an unsubscribe function | Called with { wallet, inventory, reason } when the cached wallet or inventory changed. |
gc.refresh() | { wallet, inventory } | Reads the server and updates the cache. |
Every option and every error is in the Browser SDK reference. For a longer walkthrough, see A game without a backend.
Path B: with a backend
Your server is the authority: it declares the players, credits and spends, and gives each browser a token. The examples have a curl version and a Node.js version. The URL is this server's own.
Set your secret key, and install the Node.js SDK if you use it:
export GAMECOIN_SECRET_KEY="gc_sk_test_…" # your secret key: server side only
npm install @apilow/gamecoin # Node.js 18 or laterEvery Node.js example in this documentation starts from this client, gamecoin, and from the ext helper. The other server languages are in Server SDKs.
import { GameCoin, ext } from "@apilow/gamecoin";
const gamecoin = new GameCoin(process.env.GAMECOIN_SECRET_KEY, { baseUrl: "https://gamecoin.apilow.com" });Step 1: Declare a player
A player is your own id behind ext:. Any PUT or POST on an unknown ext: player creates it, so there is nothing to synchronize beforehand. The id is URL-encoded exactly once: encodeURIComponent("ext:user-42") is ext%3Auser-42, and the SDK's ext("user-42") does it for you.
curl -X PUT "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"displayName":"Player 42","country":"BE"}'const player = await gamecoin.players.upsert(ext("user-42"), { displayName: "Player 42", country: "BE" });
console.log(player.kind, player.country); // external BEplayer = gamecoin.players.upsert(ext("user-42"), display_name="Player 42", country="BE")
print(player.kind, player.country) # external BE$player = $gamecoin->players->upsert(PlayerRef::ext('user-42'), ['display_name' => 'Player 42', 'country' => 'BE']);
echo $player->kind, ' ', $player->country, "\n"; // external BEname, country := "Player 42", "BE"
player, err := client.Players.Upsert(ctx, gamecoin.Ext("user-42"), &gamecoin.PlayerProfile{DisplayName: &name, Country: &country})
if err != nil {
log.Fatal(err)
}
fmt.Println(player.Kind, *player.Country) // external BEvar player = await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { DisplayName = "Player 42", Country = "BE" });
Console.WriteLine($"{player.Kind} {player.Country}"); // external BE{"id":"6ac4199eb5efc66d69f4d301","externalId":"user-42","kind":"external","displayName":"Player 42","country":"BE","blocked":false,"createdAt":"2026-10-05T21:41:50.328Z"}Step 2: Credit and spend
wallet/grant always credits the free (« bonus ») part of the balance; coins bought with real money come only from paid orders. Both calls need an Idempotency-Key (see below). Build it from the event you are paying for.
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: welcome-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":100,"reason":"welcome-bonus"}'
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/spend" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: revive-user-42-1" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":30,"reason":"revive"}'await gamecoin.wallet.grant(ext("user-42"), { currency: "gems", amount: 100, reason: "welcome-bonus" }, { idempotencyKey: "welcome-user-42" });
const { balance } = await gamecoin.wallet.spend(ext("user-42"), { currency: "gems", amount: 30, reason: "revive" }, { idempotencyKey: "revive-user-42-1" });
console.log(balance.total); // 70gamecoin.wallet.grant(ext("user-42"), currency="gems", amount=100, reason="welcome-bonus", idempotency_key="welcome-user-42")
movement = gamecoin.wallet.spend(ext("user-42"), currency="gems", amount=30, reason="revive", idempotency_key="revive-user-42-1")
print(movement.balance.total) # 70$gamecoin->wallet->grant(PlayerRef::ext('user-42'), ['currency' => 'gems', 'amount' => 100, 'reason' => 'welcome-bonus'], ['idempotency_key' => 'welcome-user-42']);
$movement = $gamecoin->wallet->spend(PlayerRef::ext('user-42'), ['currency' => 'gems', 'amount' => 30, 'reason' => 'revive'], ['idempotency_key' => 'revive-user-42-1']);
echo $movement->balance->total, "\n"; // 70if _, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), gamecoin.GrantParams{Currency: "gems", Amount: 100, Reason: "welcome-bonus"}, gamecoin.WithIdempotencyKey("welcome-user-42")); err != nil {
log.Fatal(err)
}
spent, err := client.Wallet.Spend(ctx, gamecoin.Ext("user-42"), gamecoin.SpendParams{Currency: "gems", Amount: 30, Reason: "revive"}, gamecoin.WithIdempotencyKey("revive-user-42-1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(spent.Balance.Total) // 70await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), new GrantRequest { Currency = "gems", Amount = 100, Reason = "welcome-bonus" }, new RequestOptions { IdempotencyKey = "welcome-user-42" });
var spent = await gamecoin.Wallet.SpendAsync(PlayerRef.Ext("user-42"), new SpendRequest { Currency = "gems", Amount = 30, Reason = "revive" }, new RequestOptions { IdempotencyKey = "revive-user-42-1" });
Console.WriteLine(spent.Balance.Total); // 70{"entry":{"id":"6ac4199e1be9a8701b926925","type":"spend","currency":"gems","paidDelta":0,"bonusDelta":-30,
"balanceAfter":{"paid":0,"bonus":70,"total":70},"reason":"revive","metadata":{},"ref":{},"createdAt":"2026-10-05T21:41:50.922Z"},
"balance":{"currency":"gems","paid":0,"bonus":70,"total":70}}Spending more than the balance answers 409 INSUFFICIENT_FUNDS and changes nothing. Read a balance with GET /players/ext%3Auser-42/wallet and the history with GET …/ledger.
Step 3: Give the browser a token, then use the SDK
The token (gc_pt_…, valid one hour) lets the browser act as that one player with your publishable key. Ask for it from your server, never from the browser with the secret key.
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/tokens" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"const { token, expiresAt } = await gamecoin.players.createToken(ext("user-42"));
console.log(token.startsWith("gc_pt_"), expiresAt.toISOString()); // true 2026-10-05T22:41:51.000Ztoken = gamecoin.players.create_token(ext("user-42"))
print(token.token.startswith("gc_pt_"), token.expires_at.isoformat()) # True 2026-10-05T22:41:51+00:00$token = $gamecoin->players->createToken(PlayerRef::ext('user-42'));
echo var_export(str_starts_with($token->token, 'gc_pt_'), true), ' ', $token->expiresAt->format(DATE_ATOM), "\n"; // true 2026-10-05T22:41:51+00:00token, err := client.Players.CreateToken(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
fmt.Println(strings.HasPrefix(token.Token, "gc_pt_"), token.ExpiresAt.Format(time.RFC3339)) // true 2026-10-05T22:41:51Zvar token = await gamecoin.Players.CreateTokenAsync(PlayerRef.Ext("user-42"));
Console.WriteLine($"{token.Token.StartsWith("gc_pt_")} {token.ExpiresAt:u}"); // True 2026-10-05 22:41:51Z{"token":"gc_pt_…","expiresAt":"2026-10-05T22:41:51.000Z"}Put that call behind an endpoint of your own, behind your login:
app.get("/api/gamecoin-token", async (req, res) => {
const { token } = await gamecoin.players.createToken(ext(req.user.id)); // the logged-in player only
res.json({ token });
});Then start the browser SDK with it:
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
const getToken = async () => (await fetch("/api/gamecoin-token").then((r) => r.json())).token;
const gc = await GameCoin.init({
publishableKey: "gc_pk_test_…",
playerToken: await getToken(), // the SDK creates no player: it plays as this one
onTokenExpired: getToken, // called once when the token (valid 1 hour) is rejected
});
console.log(gc.balance("gems")); // 70: the balance your server just set
await gc.checkout("gems-500"); // same as path A from here
</script>With playerToken the SDK never calls the player-creation routes. When the token is rejected it calls onTokenExpired once, then replays the request with the same idempotency key.
Tip
A refund takes back the coins and items of an order. It needs the secret key: the browser cannot refund. POST /orders/{orderId}/refund with an optional reason. See Refunds.
The two keys
| Publishable key | Secret key | |
|---|---|---|
| Looks like | gc_pk_test_… | gc_sk_test_… |
| Where it lives | In your game's code, in the browser | On your server only. Never in a browser, an app bundle or a repository |
| How it is sent | X-GameCoin-Key: gc_pk_… | Authorization: Bearer gc_sk_… |
| Can | Read the catalog, create an anonymous player. With that player's token (Authorization: Bearer gc_pt_…): read their wallet and inventory, spend, buy an item with a currency, consume, pay for a pack. | The whole server API: declare players, grant and spend on any player, give or take items, start checkouts, read and refund orders. |
| Can never | Credit anything: no grant, no item grant, no balance correction, no refund. | Nothing is off limits, which is why it stays on your server. |
Warning
Code that runs in a browser can be edited by the player, so GameCoin never lets it add to a balance. Only a completed payment or your server can. That is what keeps a game without a backend safe for real money.
The key also decides the game and the environment (test or live); nothing in a request body can change that. If a secret key leaks, revoke it and create another one from the dashboard. More in Keys and environments and the Security model.
Idempotency
A request that changes a wallet or an inventory, or starts a checkout, must carry an Idempotency-Key header (1 to 100 printable ASCII characters). The same key with the same request returns the original answer, with Idempotent-Replayed: true and no second effect. The same key with a different request answers 409 IDEMPOTENCY_CONFLICT. Keys are kept 30 days. A missing key is a 400 IDEMPOTENCY_KEY_REQUIRED.
The SDK does it for you: one random key per call, reused on every automatic retry (network error, 5xx, expired token). To make a call safe across page reloads, pass your own key as the last argument: gc.spend("gems", 5, "level-3", { idempotencyKey: "level-3-entry" }).
From your server, derive the key from the event you are paying for, not from a random value: if your request times out and you send it again, the same key guarantees it counts once.
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: level-3-reward-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":10,"reason":"level-3"}'
# Run it twice: the second answer is identical and carries "Idempotent-Replayed: true". Nothing is credited twice.const params = { currency: "gems", amount: 10, reason: "level-3" };
const options = { idempotencyKey: "level-3-reward-user-42" };
const first = await gamecoin.wallet.grant(ext("user-42"), params, options);
const again = await gamecoin.wallet.grant(ext("user-42"), params, options);
console.log(first.replayed, again.replayed); // false true: credited onceparams = {"currency": "gems", "amount": 10, "reason": "level-3"}
key = "level-3-reward-user-42"
first = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
again = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
print(first.replayed, again.replayed) # False True: credited once$params = ['currency' => 'gems', 'amount' => 10, 'reason' => 'level-3'];
$options = ['idempotency_key' => 'level-3-reward-user-42'];
$first = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
$again = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
echo var_export($first->replayed, true), ' ', var_export($again->replayed, true), "\n"; // false true: credited onceparams := gamecoin.GrantParams{Currency: "gems", Amount: 10, Reason: "level-3"}
options := gamecoin.WithIdempotencyKey("level-3-reward-user-42")
first, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
log.Fatal(err)
}
again, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
log.Fatal(err)
}
fmt.Println(first.Replayed, again.Replayed) // false true: credited oncevar grantRequest = new GrantRequest { Currency = "gems", Amount = 10, Reason = "level-3" };
var options = new RequestOptions { IdempotencyKey = "level-3-reward-user-42" };
var first = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
var again = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
Console.WriteLine($"{first.Replayed} {again.Replayed}"); // False True: credited onceDetails and rules: Idempotency.
Common errors
Every error has the same shape; the SDK turns it into a GameCoinError with code, status, message and details.
{ "error": { "code": "INSUFFICIENT_FUNDS", "message": "Insufficient funds", "details": { "required": 50, "available": 20 } } }| Code | HTTP | Meaning and what to do |
|---|---|---|
INSUFFICIENT_FUNDS | 409 | The wallet (or the item quantity) is too low. details.required and details.available say by how much: offer a pack. |
LIMIT_REACHED | 409 | An item's ownership cap or a pack's per-player limit is reached. |
PLAYER_BLOCKED | 403 | The player is blocked in the dashboard: every client call and every wallet or inventory write is refused. |
UNAUTHENTICATED | 401 | Unknown or revoked key, or an expired or invalid player token. The SDK renews a token once by itself; if it persists, check the key. |
FORBIDDEN | 403 | Not allowed here: a secret key on a client route, anonymous players turned off, or an item that cannot be bought from the game. |
VALIDATION_FAILED | 400 | The request is invalid. Bodies are strict: an unknown field is refused. details.fieldErrors says which one. |
NOT_FOUND | 404 | Unknown player, currency, item, pack or order for this game and environment. |
IDEMPOTENCY_CONFLICT | 409 | The same Idempotency-Key was sent with a different request. |
PAYMENT_FAILED | 402 | The payment was refused, or no payment provider exists for this environment. |
RATE_LIMITED | 429 | Too many requests. Retry-After says how many seconds to wait; the SDK waits and retries. |
NETWORK_ERROR | 0 | SDK only: GameCoin could not be reached after three attempts. The operation may or may not have been applied: refresh the wallet, or pass your own idempotencyKey so that a retry cannot count twice. |
try {
await gc.spend("gems", 50, "continue");
} catch (error) {
if (error.code === "INSUFFICIENT_FUNDS") {
const { required, available } = error.details; // 50, 20
showShop(required - available); // offer a pack
} else {
throw error; // GameCoinError: code, status, message, details
}
}The full table is on Errors.
Test and live
The key carries the environment: gc_pk_test_… and gc_sk_test_… work in test, gc_pk_live_… and gc_sk_live_… in live. The catalog (currencies, items, packs) is shared; players, balances, ledger and orders are separate in each.
- Test is the only environment you can use today. Payments are simulated: the payment page shows « Payer » and « Refuser » buttons (it is in French for now), and no money moves. Test data is disposable.
- Live is reserved for verified studios and real payments, which are not available yet. The environment is chosen by the key, so the code you write today does not change between the two.
Try it
The demo game is a small clicker built on the SDK: it reads your catalog, shows the balance live, sells your packs and items, and lets you consume them. Open it from your game's overview in the dashboard (it passes your test key), or paste a publishable test key on its start screen.
Next: Core concepts, then Testing.