Concepts
Players and tokens
How a player is named, when GameCoin creates one for you, and how a browser proves which player it acts for.
On this page
Two kinds of player
| Kind | kind | Named by | Who creates it |
|---|---|---|---|
| Your player | external | ext:<your id> | Your server, the first time it writes to that id |
| Anonymous player | hosted | The id GameCoin generated | The browser SDK, for a game without a backend |
Every player also has a GameCoin id: 24 hexadecimal characters such as 6ac419fa60e877541898f38d. It is stable, and it is the id the API puts in every answer.
Name a player in a URL
In a server API path, {player} is either of these:
- a GameCoin id:
6ac419fa60e877541898f38d; - the id your game already uses, behind
ext::ext:user-42.
Your own id is opaque and case-sensitive. It has 1 to 128 characters and no leading or trailing whitespace. GameCoin never reads meaning into it.
URL-encode the whole reference exactly once. ext:user-42 becomes ext%3Auser-42. In JavaScript this is encodeURIComponent("ext:" + id). Encoding a second time, or not at all, is the classic mistake. An id with a space, an accent or a slash shows why:
| Your id | Reference | In the path (encoded once) |
|---|---|---|
user-42 | ext:user-42 | ext%3Auser-42 |
Ana María/7 | ext:Ana María/7 | ext%3AAna%20Mar%C3%ADa%2F7 |
A reference encoded twice (ext%253Auser-42) answers 400 VALIDATION_FAILED with the field error PLAYER_REF_INVALID.
curl "https://gamecoin.apilow.com/api/v1/players/ext%3AAna%20Mar%C3%ADa%2F7" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"import { ext } from "@apilow/gamecoin";
// ext() builds the reference, and the SDK encodes it exactly once.
const player = await gamecoin.players.get(ext("Ana María/7"));
console.log(player.externalId); // "Ana María/7"from gamecoin import ext
# ext() builds the reference, and the SDK encodes it exactly once.
player = gamecoin.players.get(ext("Ana María/7"))
print(player.external_id) # Ana María/7use Apilow\GameCoin\PlayerRef;
// PlayerRef::ext() builds the reference, and the SDK encodes it exactly once.
$player = $gamecoin->players->get(PlayerRef::ext('Ana María/7'));
echo $player->externalId, "\n"; // Ana María/7// gamecoin.Ext() builds the reference, and the SDK encodes it exactly once.
player, err := client.Players.Get(ctx, gamecoin.Ext("Ana María/7"))
if err != nil {
log.Fatal(err)
}
fmt.Println(*player.ExternalID) // Ana María/7using Apilow.GameCoin;
// PlayerRef.Ext() builds the reference, and the SDK encodes it exactly once.
var player = await gamecoin.Players.GetAsync(PlayerRef.Ext("Ana María/7"));
Console.WriteLine(player.ExternalId); // Ana María/7GameCoin creates your players
You do not synchronize your players with GameCoin. With a secret key, any PUT or POST on an ext: id that does not exist yet creates the player. Writing to the wallet, the inventory, a purchase or a checkout all count. A GET on an unknown id answers 404 NOT_FOUND.
PUT /players/{player} creates a player or updates its profile:
/players/{player}| Field | Meaning |
|---|---|
displayName | A name shown in the dashboard. Up to 200 characters. |
email | The player's e-mail address, if you have one. Up to 254 characters. |
country | An ISO 3166-1 country code, for example BE. It sets the default country for VAT on checkouts. |
birthYear | The year of birth, if you know it. |
Every field is optional. A field you leave out is not touched, and null clears it. An empty body means {}.
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":"Ana","country":"BE"}'const player = await gamecoin.players.upsert(ext("user-42"), { displayName: "Ana", country: "BE" });
console.log(player.kind, player.country); // external BE
// null clears a field; a missing field is left alone.
await gamecoin.players.upsert(ext("user-42"), { country: null });player = gamecoin.players.upsert(ext("user-42"), display_name="Ana", country="BE")
print(player.kind, player.country) # external BE
# None clears a field; a missing field is left alone.
gamecoin.players.upsert(ext("user-42"), country=None)$player = $gamecoin->players->upsert(PlayerRef::ext('user-42'), ['display_name' => 'Ana', 'country' => 'BE']);
echo $player->kind, ' ', $player->country, "\n"; // external BE
// null clears a field; a missing key is left alone.
$gamecoin->players->upsert(PlayerRef::ext('user-42'), ['country' => null]);name, country := "Ana", "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 BE
// Clear lists the fields to erase; a nil field is left alone.
if _, err := client.Players.Upsert(ctx, gamecoin.Ext("user-42"), &gamecoin.PlayerProfile{Clear: []gamecoin.ProfileField{gamecoin.FieldCountry}}); err != nil {
log.Fatal(err)
}var player = await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { DisplayName = "Ana", Country = "BE" });
Console.WriteLine($"{player.Kind} {player.Country}"); // external BE
// Clear lists the fields to erase; a null property is left alone.
await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { Clear = PlayerProfileFields.Country });{
"id": "6ac41a8d4c79ef7265372fa9",
"externalId": "user-42",
"kind": "external",
"displayName": "Ana",
"country": "BE",
"blocked": false,
"createdAt": "2026-10-05T21:45:49.967Z"
}externalId is your id without the ext:. blocked is true after you block the player in the dashboard.
Anonymous players
A game with no server cannot name its players. The browser SDK asks GameCoin for an anonymous player the first time the game runs. GameCoin answers with:
- the player;
- a
playerSecret, shown once; - a first token and its expiry.
{
"player": {
"id": "6ac41a4259b32e38d077a85f",
"externalId": null,
"kind": "hosted",
"displayName": "Guest",
"country": null,
"blocked": false,
"createdAt": "2026-10-05T21:44:34.398Z"
},
"playerSecret": "…",
"token": "gc_pt_…",
"expiresAt": "2026-10-05T22:44:34.000Z"
}The SDK keeps the player id and the secret in the browser's localStorage. On the next visit it trades them for a fresh token, so the same browser is always the same player with the same balance. The secret never leaves that browser, and it cannot be recovered: clearing the browser's data creates a new player.
| Fact | Value |
|---|---|
| Creating an anonymous player | Limited to 30 per hour per IP address |
| Turning it off | The game setting « Autoriser les joueurs anonymes » in the dashboard (in French). POST /client/players then answers 403 FORBIDDEN. |
| Wrong secret, unknown player, or a player that is not anonymous | 401 UNAUTHENTICATED on POST /client/players/token |
An anonymous player has no ext: id, so your server can use it only by its GameCoin id.
Player tokens
A player token lets a browser act as exactly one player, with your publishable key. It looks like gc_pt_…, lasts one hour, and is signed by GameCoin. It names the player, the game and the environment, so it cannot be used on another game or in the other environment.
A token proves which player is calling. It adds no power: a player can read their own wallet, spend, buy an item with a currency, consume an item and pay for a pack, and nothing else. Security model lists it all.
Where a token comes from depends on your game:
| Your game | Where the token comes from |
|---|---|
| Has a server | Your server asks GameCoin with the secret key and hands the token to the browser |
| Has no server | The browser SDK gets it by itself, with the anonymous player's secret |
Issue a token from your server
/players/{player}/tokenscurl -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); // true 2026-10-05T22:41:51.000Ztoken = gamecoin.players.create_token(ext("user-42"))
print(token.token.startswith("gc_pt_"), token.expires_at) # True 2026-10-05 22: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) // true 2026-10-05 22:41:51 +0000 UTCvar 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" }This call creates the ext: player if it does not exist. It needs no idempotency key: asking twice just gives two valid tokens. Keep it behind your own login: whoever can call your token endpoint can act as that player.
Renew a token
A token is not revocable, so it is kept short. When a request answers 401 UNAUTHENTICATED, the token is invalid or expired; the two cases cannot be told apart.
- Anonymous player: the browser SDK renews the token by itself with the stored secret, then replays the request with the same idempotency key.
- Player with a server: pass
onTokenExpiredto the SDK. It calls your function once, takes the token you return, and replays the request. Your function should fetch a new token from your server.
The browser SDK page shows both with code: Browser SDK.
Blocking a player
Blocking a player in the dashboard stops them at once, even if their token is still valid. Every client call, and every wallet or inventory write from your server, answers 403 PLAYER_BLOCKED. Balances and history are kept, and you can unblock the player at any time.
Where next
- Keys and environments: which key goes where.
- A game without a backend and A game with a backend: both flows, step by step.