Guides
A game with a backend
Run GameCoin from your own server, reward players there, hand each browser a one-hour token, and use the browser SDK for the rest.
On this page
What you will build
Your server is the authority. It knows its players, decides what they earn, and credits them. The browser gets a short-lived token and uses the SDK to show the balance, spend, sell packs and use items. In this guide you will:
- Install the server SDK and make a client.
- Reward a player from your server.
- Give the browser a token.
- Start the browser SDK with that token.
- Let the server spend when the server must decide.
If your game has no server, read A game without a backend instead. The catalog set-up (a gems currency, a potion item, a gems-500 pack) is described there.
Step 1: Install and connect
You need Node.js 18 or later for the Node.js examples. Every example also has a curl version, and the other server languages are in Server SDKs.
npm install @apilow/gamecoinPut the secret key of your game (the gc_sk_test_… key from « Clés d'API » in the dashboard, which is in French) in the environment of your server. It never goes in your code or in the browser.
export GAMECOIN_SECRET_KEY="gc_sk_test_…"import { GameCoin, ext } from "@apilow/gamecoin";
export const gamecoin = new GameCoin(process.env.GAMECOIN_SECRET_KEY, { baseUrl: "https://gamecoin.apilow.com" });
export { ext };baseUrl is where GameCoin runs. The SDK adds /api/v1. The other examples on this page use gamecoin and ext from this file. The .mjs extension makes Node.js read the files as modules without any other set-up.
Step 2: Name your players
A player is your own id behind ext:. You do not need to declare players first: the first write for an id creates it. When you do want to store a name or a country, send the profile. The id is URL-encoded exactly once; the SDK's ext() and the %3A below do that.
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.externalId); // external user-42player = gamecoin.players.upsert(ext("user-42"), display_name="Player 42", country="BE")
print(player.kind, player.external_id) # external user-42$player = $gamecoin->players->upsert(PlayerRef::ext('user-42'), ['display_name' => 'Player 42', 'country' => 'BE']);
echo $player->kind, ' ', $player->externalId, "\n"; // external user-42name, 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.ExternalID) // external user-42var player = await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { DisplayName = "Player 42", Country = "BE" });
Console.WriteLine($"{player.Kind} {player.ExternalId}"); // external user-42{
"id": "6ac4199eb5efc66d69f4d301",
"externalId": "user-42",
"kind": "external",
"displayName": "Player 42",
"country": "BE",
"blocked": false,
"createdAt": "2026-10-05T21:41:50.328Z"
}Step 3: Reward a player from your server
Rewards are yours to give: a level, a quest, a daily bonus. Your server checks that the event really happened, then grants. A grant always credits the free (bonus) part of the balance.
Build the idempotency key from the event, so that retrying the same event can never pay twice: level-3-user-42, not a random value.
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-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":100,"reason":"level-3"}'const { balance, replayed } = await gamecoin.wallet.grant(
ext("user-42"),
{ currency: "gems", amount: 100, reason: "level-3" },
{ idempotencyKey: "level-3-user-42" },
);
console.log(balance.total, replayed); // 100 falsemovement = gamecoin.wallet.grant(
ext("user-42"),
currency="gems",
amount=100,
reason="level-3",
idempotency_key="level-3-user-42",
)
print(movement.balance.total, movement.replayed) # 100 False$movement = $gamecoin->wallet->grant(
PlayerRef::ext('user-42'),
['currency' => 'gems', 'amount' => 100, 'reason' => 'level-3'],
['idempotency_key' => 'level-3-user-42'],
);
echo $movement->balance->total, ' ', var_export($movement->replayed, true), "\n"; // 100 falsemovement, err := client.Wallet.Grant(
ctx,
gamecoin.Ext("user-42"),
gamecoin.GrantParams{Currency: "gems", Amount: 100, Reason: "level-3"},
gamecoin.WithIdempotencyKey("level-3-user-42"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(movement.Balance.Total, movement.Replayed) // 100 falsevar movement = await gamecoin.Wallet.GrantAsync(
PlayerRef.Ext("user-42"),
new GrantRequest { Currency = "gems", Amount = 100, Reason = "level-3" },
new RequestOptions { IdempotencyKey = "level-3-user-42" });
Console.WriteLine($"{movement.Balance.Total} {movement.Replayed}"); // 100 False{
"entry": {
"id": "6ac4199e37c701028804249c",
"type": "grant",
"currency": "gems",
"paidDelta": 0,
"bonusDelta": 100,
"balanceAfter": { "paid": 0, "bonus": 100, "total": 100 },
"reason": "level-3",
"metadata": {},
"ref": {},
"createdAt": "2026-10-05T21:41:50.594Z"
},
"balance": { "currency": "gems", "paid": 0, "bonus": 100, "total": 100 }
}Send the same request again and you get the same answer, with Idempotent-Replayed: true (replayed: true in the SDK). The player still has 100 gems. See Idempotency.
Step 4: Give the browser a token
The browser must act as one player. A player token (gc_pt_…) lets it do that for one hour, together with your publishable key. Only your server can ask for it, because only your server knows who is logged in.
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" }Wrap it in an endpoint of your own, behind your login. This server uses only Node.js's built-in http module, so you can run it as it is. The line currentUser is the one to replace with your own session lookup.
import http from "node:http";
import { gamecoin, ext } from "./gamecoin.mjs";
// Replace with your own login: the id of the player who made this request.
const currentUser = (req) => req.headers["x-user-id"];
const server = http.createServer(async (req, res) => {
const send = (status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
const user = currentUser(req);
if (!user) return send(401, { error: "not logged in" });
try {
// The browser asks for a token. It can only get one for the player it is logged in as.
if (req.method === "POST" && req.url === "/api/gamecoin-token") {
const { token } = await gamecoin.players.createToken(ext(user));
return send(200, { token });
}
// The browser says a level is over. Your server decides if it is true, and what it is worth.
if (req.method === "POST" && req.url === "/api/level-complete") {
const level = 3;
const { balance } = await gamecoin.wallet.grant(
ext(user),
{ currency: "gems", amount: 10, reason: `level-${level}` },
{ idempotencyKey: `level-${level}-${user}` },
);
return send(200, { gems: balance.total });
}
send(404, { error: "not found" });
} catch (error) {
console.error("GameCoin:", error.code ?? error);
send(502, { error: "GameCoin is not available" });
}
});
server.listen(8080);Never send the secret key, or a token for another player, to the browser.
Step 5: Start the browser SDK with the token
Pass the token as playerToken. The SDK then creates no player of its own: it plays as the player of the token, and the balance it reads is the one your server just set.
A token lasts an hour, so also pass onTokenExpired. The SDK calls it once, when a request is refused because the token expired, takes the token you return, and replays the request.
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
const getToken = async () => {
const response = await fetch("/api/gamecoin-token", { method: "POST" });
return (await response.json()).token;
};
const gc = await GameCoin.init({
publishableKey: "gc_pk_test_…",
playerToken: await getToken(),
onTokenExpired: getToken, // called once when the one-hour token is rejected
});
console.log(gc.balance("gems")); // 100: what your server granted
gc.on("change", () => console.log("gems:", gc.balance("gems")));
</script>From here the browser does the same things as in A game without a backend: gc.spend, gc.buyItem, gc.consume, gc.checkout. It can read, spend and pay. It still cannot credit anything by itself: the one exception is a gift code that you created, which the player can redeem with gc.redeemCode(code) for free coins and items, once. It works with your player token too; if you want your server to decide who may redeem, call the server route instead.
When your server grants something while the page is open (a reward after /api/level-complete), tell the SDK to read again:
await fetch("/api/level-complete", { method: "POST" });
await gc.refresh(); // the "change" event fires and your screen updatesStep 6: Let the server spend when the server must decide
The browser can spend by itself, which is simple and fine for a game where nothing depends on the purchase. When your server enforces the result (an item that unlocks something the server checks, an entry fee for a match your server runs), let the server spend, and the browser only asks:
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/purchases" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: potion-user-42-order-9" \
-H "Content-Type: application/json" \
-d '{"sku":"potion","quantity":2}'const purchase = await gamecoin.purchases.create(
ext("user-42"),
{ sku: "potion", quantity: 2 },
{ idempotencyKey: "potion-user-42-order-9" },
);
console.log(purchase.balance.total, purchase.item.quantity); // 60 2purchase = gamecoin.purchases.create(
ext("user-42"),
sku="potion",
quantity=2,
idempotency_key="potion-user-42-order-9",
)
print(purchase.balance.total, purchase.item.quantity) # 60 2$purchase = $gamecoin->purchases->create(
PlayerRef::ext('user-42'),
['sku' => 'potion', 'quantity' => 2],
['idempotency_key' => 'potion-user-42-order-9'],
);
echo $purchase->balance->total, ' ', $purchase->item->quantity, "\n"; // 60 2purchase, err := client.Purchases.Create(
ctx,
gamecoin.Ext("user-42"),
gamecoin.PurchaseParams{SKU: "potion", Quantity: 2},
gamecoin.WithIdempotencyKey("potion-user-42-order-9"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(purchase.Balance.Total, purchase.Item.Quantity) // 60 2var purchase = await gamecoin.Purchases.CreateAsync(
PlayerRef.Ext("user-42"),
new PurchaseRequest { Sku = "potion", Quantity = 2 },
new RequestOptions { IdempotencyKey = "potion-user-42-order-9" });
Console.WriteLine($"{purchase.Balance.Total} {purchase.Item.Quantity}"); // 60 2purchases buys an item with its own price: the wallet is debited and the item added, in one step. If the wallet is too low, the call answers 409 INSUFFICIENT_FUNDS and nothing changes. The server SDK handles errors with GameCoinError; see Errors.
You can also read what your server needs:
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/inventory" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"const wallet = await gamecoin.wallet.get(ext("user-42"));
const items = await gamecoin.inventory.list(ext("user-42"));
console.log(wallet.balances.map((b) => `${b.currency}: ${b.total}`), items);wallet = gamecoin.wallet.get(ext("user-42"))
items = gamecoin.inventory.list(ext("user-42"))
print([f"{b.currency}: {b.total}" for b in wallet.balances], items)$wallet = $gamecoin->wallet->get(PlayerRef::ext('user-42'));
$items = $gamecoin->inventory->list(PlayerRef::ext('user-42'));
echo implode(', ', array_map(static fn ($b) => "{$b->currency}: {$b->total}", $wallet->balances)), "
";
print_r($items);wallet, err := client.Wallet.Get(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
items, err := client.Inventory.List(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
for _, b := range wallet.Balances {
fmt.Printf("%s: %d\n", b.Currency, b.Total)
}
fmt.Printf("%+v\n", items)var wallet = await gamecoin.Wallet.GetAsync(PlayerRef.Ext("user-42"));
var items = await gamecoin.Inventory.ListAsync(PlayerRef.Ext("user-42"));
Console.WriteLine(string.Join(", ", wallet.Balances.Select(b => $"{b.Currency}: {b.Total}")));
foreach (var item in items)
{
Console.WriteLine(item);
}Before you go live
- The secret key lives only in your server's environment.
/api/gamecoin-tokensits behind your login, and gives a token only for the logged-in player.- Every grant has an idempotency key made from the event.
- Your browser code uses the publishable key and the token, nothing else.
- You tried a declined payment, a player without enough gems, and an expired token. See Testing.
Where next
- Selling packs: create the checkout from your server.
- Free currencies and items: rewards, items, limits.
- Security model