# Players and tokens

> How a player is named, when GameCoin creates one for you, and how a browser proves which player it acts for.

## 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`.

<!-- tabs:start -->
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3AAna%20Mar%C3%ADa%2F7" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
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"
```
```python tab="Python"
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/7
```
```php tab="PHP"
use 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
```
```go tab="Go"
// 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/7
```
```csharp tab="C#"
using 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/7
```
<!-- tabs:end -->

## GameCoin 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:

```endpoint
PUT /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 `{}`.

<!-- tabs:start -->
```bash tab="curl"
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"}'
```
```javascript tab="Node.js"
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 });
```
```python tab="Python"
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)
```
```php tab="PHP"
$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]);
```
```go tab="Go"
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)
}
```
```csharp tab="C#"
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 });
```
<!-- tabs:end -->

```json title="Answer"
{
  "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.

```json title="Answer to POST /client/players (secret and token shortened)"
{
  "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](/docs/concepts/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

```endpoint
POST /players/{player}/tokens
```

<!-- tabs:start -->
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/tokens" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const { token, expiresAt } = await gamecoin.players.createToken(ext("user-42"));
console.log(token.startsWith("gc_pt_"), expiresAt); // true 2026-10-05T22:41:51.000Z
```
```python tab="Python"
token = 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
```
```php tab="PHP"
$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:00
```
```go tab="Go"
token, 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 UTC
```
```csharp tab="C#"
var 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
```
<!-- tabs:end -->

```json title="Answer"
{ "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 `onTokenExpired` to 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](/docs/sdk/browser#expired-tokens).

### 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](/docs/concepts/keys-and-environments): which key goes where.
- [A game without a backend](/docs/guides/no-backend-game) and [A game with a backend](/docs/guides/game-with-backend): both flows, step by step.
