Server SDKs
Node.js SDK
Install the GameCoin SDK for Node.js and use it from your game backend to manage wallets, the ledger, inventory, checkouts and refunds.
On this page
The Node.js SDK is the server SDK for backends written in JavaScript or TypeScript. It holds your secret key, so it belongs on your server, never in a browser or a game client. For the browser, use the browser SDK. Not sure which SDK you need? Start with Server SDKs.
Install
Note
The SDK is a pre-release (version 0.1.0) and is not published yet: it is not on the npm registry. The package name below is the planned one and may change before the first release.
npm install @apilow/gamecoinIt needs Node.js 18 or later and has no dependency: it uses the built-in fetch. The package ships ES modules, CommonJS and TypeScript types.
import { GameCoin, ErrorCode, GameCoinError, ext } from "@apilow/gamecoin";With CommonJS:
const { GameCoin, ErrorCode, GameCoinError, ext } = require("@apilow/gamecoin");Create the client
Create one client per game and environment, and share it. The key decides both: gc_sk_test_… talks to the test environment, gc_sk_live_… to the live one. Read it from an environment variable or a secret manager, never from your code.
const gamecoin = new GameCoin(process.env.GAMECOIN_SECRET_KEY, { baseUrl: "https://gamecoin.apilow.com" });
console.log(gamecoin.environment); // "test" or "live", read from the keyhttps://gamecoin.apilow.com is the address of this service, which is also the default: you only need baseUrl to point the SDK at another address. Every option is optional:
| Option | Default | Meaning |
|---|---|---|
baseUrl | https://gamecoin.apilow.com | Origin of the service. The SDK appends /api/v1, drops trailing slashes and keeps a path prefix. Only http and https are accepted. |
timeout | 30000 | Milliseconds to wait for one attempt, headers and body included. |
maxRetries | 2 | Retries after the first attempt, from 0 to 10: three attempts by default. 0 turns retries off. |
fetch | global fetch | Your own transport, for tests or a proxy. See Testing your integration. |
sleep | a timer | Your own wait between retries, for tests. |
const tuned = new GameCoin(process.env.GAMECOIN_SECRET_KEY, {
baseUrl: "https://gamecoin.apilow.com",
timeout: 10_000, // give up on an attempt after 10 seconds
maxRetries: 3, // retry up to three times, four attempts in all
});
console.log(tuned.environment);The constructor refuses anything that is not a secret key, before any request. A publishable key (gc_pk_…) gets a message that points to the browser SDK. The key is trimmed, so a trailing newline from a file does no harm. It never appears in an error or in a string representation: String(gamecoin), util.inspect and JSON.stringify show its prefix only (gc_sk_test_…).
Players and the ext helper
A player is a GameCoin player id, or your own id through ext():
const player = ext("user-42");
console.log(player); // "ext:user-42"Any string works as your id (spaces, slashes, % and accents included): the SDK URL-encodes the whole reference exactly once. With a secret key, a write on an unknown ext: player creates it, so there is no registration step. A read of an unknown player fails with NOT_FOUND.
const profile = await gamecoin.players.upsert(player, { displayName: "Alice", country: "BE" });
const same = await gamecoin.players.get(player);
console.log(profile.id === same.id, profile.externalId, profile.kind); // true "user-42" "external"
// A one-hour token for the browser SDK of your game client
const { token, expiresAt } = await gamecoin.players.createToken(player);
console.log(token.startsWith("gc_pt_"), expiresAt > new Date()); // true trueIn players.upsert, a field you leave out is unchanged and null clears it:
const cleared = await gamecoin.players.upsert(player, { displayName: null });
console.log(cleared.displayName, cleared.country); // null "BE"Wallet
A balance has two buckets: paid (bought with real money) and bonus (granted or earned). A grant always credits bonus. A spend takes from bonus first, then from paid, unless your game reverses that order in its settings.
const { balance } = await gamecoin.wallet.grant(player, { currency: "gems", amount: 100, reason: "welcome_gift" });
console.log(balance.total, balance.currency); // 100 "gems"
const spent = await gamecoin.wallet.spend(player, { currency: "gems", amount: 30, reason: "revive" });
console.log(spent.balance.total, spent.entry.type); // 70 "spend"amount is an integer of at least 1. reason is a short text and metadata an object of strings: both are recorded on the ledger entry.
const reward = await gamecoin.wallet.grant(player, {
currency: "gold",
amount: 25,
reason: "daily_reward",
metadata: { day: "3" },
});
console.log(reward.entry.type, reward.entry.bonusDelta, reward.balance.total); // "grant" 25 25wallet.get returns one balance per active currency, zeros included:
const wallet = await gamecoin.wallet.get(player);
for (const { currency, paid, bonus, total } of wallet.balances) console.log(currency, paid, bonus, total);
// gems 0 70 70
// gold 0 25 25Ledger
The ledger is the immutable history of every change to a wallet, newest first. ledger.list returns one page and a cursor; limit is the page size, from 1 to 100 (20 by default).
const page = await gamecoin.ledger.list(player, { currency: "gems", limit: 1 });
console.log(page.entries.length, page.nextCursor !== null); // 1 true
const older = await gamecoin.ledger.list(player, { currency: "gems", limit: 1, cursor: page.nextCursor });
console.log(older.entries[0].type); // "grant"nextCursor is null on the last page. To avoid handling cursors yourself, iterate: each page is fetched only when the loop needs it, so you can stop early.
for await (const entry of gamecoin.ledger.iterate(player, { currency: "gems", limit: 50 })) {
console.log(entry.createdAt.toISOString(), entry.type, entry.paidDelta + entry.bonusDelta, entry.balanceAfter.total);
if (entry.type === "grant") break; // no further page is fetched
}Inventory and purchases
Items are consumable (they can be consumed) or durable (they cannot), and may have a maximum per player. catalog.get lists the active currencies, items and packs of your game.
const catalog = await gamecoin.catalog.get();
console.log(catalog.items.map((item) => `${item.sku} (${item.type})`)); // [ 'potion (consumable)', 'fire-sword (durable)' ]const granted = await gamecoin.inventory.grant(player, { sku: "potion", quantity: 3 });
console.log(granted.quantity); // 3
const used = await gamecoin.inventory.consume(player, { sku: "potion" }); // quantity defaults to 1
console.log(used.quantity); // 2
const owned = await gamecoin.inventory.list(player); // owned items only (quantity above 0)
console.log(owned.map((item) => `${item.sku} x${item.quantity}`)); // [ 'potion x2' ]inventory.consume returns the item even when it reaches 0. purchases.create buys an item with its price in currency, atomically: the wallet is debited and the item added, or nothing happens.
const purchase = await gamecoin.purchases.create(player, { sku: "potion" });
console.log(purchase.balance.total, purchase.item.quantity, purchase.entry.ref.itemSku); // 50 3 "potion"An item can limit how many a player owns. Going beyond fails with LIMIT_REACHED:
await gamecoin.inventory.grant(player, { sku: "fire-sword" });
try {
await gamecoin.inventory.grant(player, { sku: "fire-sword" }); // limited to one per player
} catch (error) {
console.log(error instanceof GameCoinError && error.code === ErrorCode.LIMIT_REACHED); // true
}Checkout, orders and refunds
A checkout sells a currency pack for real money through a hosted payment page. You create the order, send the player to checkoutUrl, and the coins and items are delivered when the payment succeeds.
const checkout = await gamecoin.checkouts.create(player, {
packSku: "gems-500",
successUrl: "https://example.com/shop/thanks", // where the player goes after paying
cancelUrl: "https://example.com/shop",
country: "BE", // the buyer's country decides the VAT (EU countries only)
});
console.log(checkout.order.status, checkout.checkoutUrl.startsWith("http")); // "pending" true
const order = await gamecoin.orders.get(checkout.order.id);
console.log(order.status, order.amountCents, order.vatCents, order.netCents); // "pending" 499 87 412The test environment simulates payments: the hosted page offers « Payer » and « Refuser » buttons (it is in French for now), and no money moves. Real payments are not available yet. After a payment the order goes from pending to paid to fulfilled, and the coins and items are delivered. Poll orders.get, or wait for the player to come back to your successUrl, before telling them it worked.
orders.refund takes the coins and items back from a paid order and returns the order, with status refunded. What a refund does to coins that the player already spent is explained in Refunds. An order that was never paid answers CONFLICT:
try {
await gamecoin.orders.refund(order.id, { reason: "player request" });
} catch (error) {
if (error instanceof GameCoinError && error.code === ErrorCode.CONFLICT) console.log("not refundable:", error.message);
else throw error;
}Warning
A refund has no idempotency key, so the SDK retries it on 429 only. After a network error, a timeout or a 5xx the call fails and the outcome is unknown: read the order back with orders.get before you try again.
Idempotency and retries
Every call that changes something (wallet.grant, wallet.spend, inventory.grant, inventory.consume, purchases.create, checkouts.create) sends an Idempotency-Key. By default the SDK generates a UUID v4 once per call and reuses it for every retry of that call, so a retry can never apply a change twice.
Pass your own key to make a retry of your code safe too, for instance when a job may run twice. Use 1 to 100 printable ASCII characters, derived from the event (here a quest completion). The result tells whether the answer came from an earlier request:
const idempotencyKey = "quest-17:user-42";
const first = await gamecoin.wallet.grant(player, { currency: "gems", amount: 10 }, { idempotencyKey });
const again = await gamecoin.wallet.grant(player, { currency: "gems", amount: 10 }, { idempotencyKey });
console.log(first.replayed, again.replayed, first.entry.id === again.entry.id); // false true trueThe same key with a different request is refused with IDEMPOTENCY_CONFLICT. Keys are kept for 30 days. A replayed checkout returns the order in its current state. The rules behind this are on the Idempotency page.
Automatic retries run at most maxRetries times (twice by default):
| Failure | Retried |
|---|---|
Network error, timeout, 5xx | Yes (not for orders.refund) |
429 | Yes, after the Retry-After delay |
Any other 4xx | Never |
The wait is 0.5 s × 2^attempt, with a random jitter of ±25 %. A Retry-After header wins, up to 30 seconds. Beyond that nothing is retried and the error carries retryAfter, in seconds, so that you decide. Every read, players.upsert and players.createToken follow the same rules as the changes above.
Every call accepts an AbortSignal that cancels it, and any retry in progress. The call then rejects with the signal's reason, not with a GameCoinError:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
const latest = await gamecoin.wallet.get(player, { signal: controller.signal });
clearTimeout(timer);
console.log(latest.balances.length); // 2Errors
Anything that goes wrong with a request is a GameCoinError:
| Property | Content |
|---|---|
code | The API code (INSUFFICIENT_FUNDS, NOT_FOUND…), or NETWORK_ERROR or TIMEOUT (status 0), or INVALID_RESPONSE when the body is not the expected JSON. |
status | The HTTP status, 0 when no response was received. |
message | The message of the API, or a short description. Written for developers, not for your players. |
details | The details of the API (required, available, fieldErrors…), {} when absent. |
retryAfter | Seconds, when the API sent Retry-After. |
try {
await gamecoin.wallet.spend(player, { currency: "gems", amount: 1_000_000 });
} catch (error) {
if (error instanceof GameCoinError && error.code === ErrorCode.INSUFFICIENT_FUNDS) {
console.log(`needs ${error.details.required}, has ${error.details.available}`); // needs 1000000, has 60
} else {
throw error;
}
}ErrorCode holds a constant for every code. These are the ones you will meet:
| HTTP | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | A field is invalid (details.fieldErrors). |
| 401 | UNAUTHENTICATED | The secret key is missing, unknown or revoked. |
| 402 | PAYMENT_FAILED | The payment was refused, or no payment provider exists for this environment. |
| 403 | FORBIDDEN | This kind of key is not allowed here. |
| 403 | PLAYER_BLOCKED | The player is blocked. |
| 403 | ENV_NOT_ENABLED | The live environment is used by a studio that is not verified. |
| 404 | NOT_FOUND | Unknown player, currency, item, pack or order. |
| 409 | INSUFFICIENT_FUNDS | The wallet or the item quantity is too low (details.required, details.available). |
| 409 | LIMIT_REACHED | maxOwned, maxPerPlayer or the balance ceiling is exceeded. |
| 409 | IDEMPOTENCY_CONFLICT | The key was reused with another request. |
| 409 | CONFLICT | Invalid state change, such as refunding an unpaid order. |
| 429 | RATE_LIMITED | Too many requests (retryAfter). |
| 500 | INTERNAL | Unexpected error on the GameCoin side. |
| 0 | NETWORK_ERROR, TIMEOUT | No answer. The SDK has already retried. |
| any | INVALID_RESPONSE | The body is not the expected JSON. |
IDEMPOTENCY_KEY_REQUIRED also exists, but the SDK always sends a key. See Errors for what each code means for your game.
Arguments that are wrong on their face never reach the network. A wrong type throws a TypeError and an unacceptable value a RangeError, not a GameCoinError, and the message never contains the offending value:
try {
await gamecoin.wallet.grant(player, { currency: "gems", amount: -5 });
} catch (error) {
console.log(error.name, error instanceof GameCoinError); // "RangeError" false
}When the ES module and CommonJS copies of the package can meet in one program, test with GameCoinError.isGameCoinError(error) instead of instanceof.
Testing your integration
The transport is an option, so your own tests need no network and no mocking library. fetch receives the URL and the request, and returns anything shaped like a Response: a status, headers.get() and text(). The global Response fits. With sleep, you also skip the waits between retries:
const calls = [];
let attempts = 0;
const fakeFetch = async (url, init) => {
calls.push({ method: init.method, key: init.headers["Idempotency-Key"] });
attempts += 1;
if (attempts < 3) return new Response("Bad gateway", { status: 502 });
const body = { playerId: "p1", balances: [{ currency: "gems", paid: 0, bonus: 100, total: 100 }] };
return new Response(JSON.stringify(body), { status: 200 });
};
const offline = new GameCoin(`gc_sk_test_${"x".repeat(40)}`, { fetch: fakeFetch, sleep: async () => {} });
const result = await offline.wallet.get(player);
console.log(result.balances[0].total, calls.length); // 100 3The same transport can check a change: two failures and a success, and a single idempotency key sent three times.
const sent = [];
const echo = new GameCoin(`gc_sk_test_${"x".repeat(40)}`, {
sleep: async () => {},
fetch: async (url, init) => {
sent.push(init.headers["Idempotency-Key"]);
return sent.length < 3
? new Response("Bad gateway", { status: 502 })
: new Response(JSON.stringify({ item: { sku: "potion", quantity: 1, updatedAt: "2026-10-05T12:00:00.000Z" } }), { status: 200 });
},
});
const gift = await echo.inventory.grant(player, { sku: "potion" });
console.log(gift.quantity, new Set(sent).size); // 1 1To test against the real service without touching your players, use a test secret key (gc_sk_test_…) of your game. The test environment runs the same API with simulated payments, and its data never mixes with the live data: see Keys and environments. The Testing guide says what to try. Use a fresh player id for each run, so that every run starts from a zero balance:
const sandbox = new GameCoin(process.env.GAMECOIN_SECRET_KEY, { baseUrl: "https://gamecoin.apilow.com" });
const fresh = ext(`test-${Date.now()}`);
const start = await sandbox.wallet.grant(fresh, { currency: "gems", amount: 100 });
console.log(start.balance.total); // 100Reference
player is a string: a GameCoin player id, or the result of ext(). Every call returns a Promise. Every call takes a last options argument with an optional signal (an AbortSignal); the calls marked with an asterisk also accept an idempotencyKey.
| Method | Returns | HTTP request |
|---|---|---|
gamecoin.catalog.get(options?) | Promise<Catalog> | GET /catalog |
gamecoin.players.upsert(player, profile?, options?) | Promise<Player> | PUT /players/{player} |
gamecoin.players.get(player, options?) | Promise<Player> | GET /players/{player} |
gamecoin.players.createToken(player, options?) | Promise<PlayerToken> | POST /players/{player}/tokens |
gamecoin.wallet.get(player, options?) | Promise<Wallet> | GET /players/{player}/wallet |
gamecoin.wallet.grant(player, params, options?) * | Promise<Movement> | POST /players/{player}/wallet/grant |
gamecoin.wallet.spend(player, params, options?) * | Promise<Movement> | POST /players/{player}/wallet/spend |
gamecoin.ledger.list(player, params?, options?) | Promise<LedgerPage> | GET /players/{player}/ledger |
gamecoin.ledger.iterate(player, params?, options?) | AsyncGenerator<LedgerEntry> | GET /players/{player}/ledger, page after page |
gamecoin.inventory.list(player, options?) | Promise<InventoryItem[]> | GET /players/{player}/inventory |
gamecoin.inventory.grant(player, params, options?) * | Promise<InventoryChange> | POST /players/{player}/inventory/grant |
gamecoin.inventory.consume(player, params, options?) * | Promise<InventoryChange> | POST /players/{player}/inventory/consume |
gamecoin.purchases.create(player, params, options?) * | Promise<Purchase> | POST /players/{player}/purchases |
gamecoin.checkouts.create(player, params, options?) * | Promise<Checkout> | POST /players/{player}/checkouts |
gamecoin.orders.get(orderId, options?) | Promise<Order> | GET /orders/{orderId} |
gamecoin.orders.refund(orderId, params?, options?) | Promise<Order> | POST /orders/{orderId}/refund |
A parameter object rejects any field it does not know, so a typo fails at once instead of being dropped.
| Parameters | Fields |
|---|---|
profile | displayName, email, country, birthYear: a string (a number for birthYear), or null to clear |
params of wallet.grant and wallet.spend | currency and amount (required), reason, metadata (strings only) |
params of inventory.grant, inventory.consume and purchases.create | sku (required), quantity (1 by default) |
params of ledger.list | currency, limit (1 to 100), cursor |
params of ledger.iterate | currency, limit (the page size) |
params of checkouts.create | packSku (required), successUrl, cancelUrl, country |
params of orders.refund | reason |
The results are plain objects, with Date values for every timestamp:
| Type | Fields |
|---|---|
Movement | entry (a ledger entry), balance, replayed |
Purchase | entry, balance, item, replayed |
Checkout | order, checkoutUrl, replayed |
InventoryChange | sku, quantity, updatedAt, replayed |
LedgerPage | entries, nextCursor (null on the last page) |
PlayerToken | token, expiresAt |
The package also exports GameCoin, GameCoinError, ErrorCode, ext, VERSION, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS and DEFAULT_MAX_RETRIES, and a TypeScript type for every object of the API (Player, Wallet, Balance, LedgerEntry, InventoryItem, Catalog, Order…). The fields of each object match the API reference, where each resource has its page: catalog, players, wallet, ledger, inventory, purchases, checkouts and orders.