Guides
Sign in players with their e-mail
Let an anonymous player link an e-mail address with a six-digit code, and find the same player again on another device, without a password and without a backend.
On this page
What you will build
A game without a backend creates an anonymous player in the browser. The player's identity lives in the playerSecret that the browser SDK keeps in the browser storage. Clear the storage, switch phone or open the game on a computer, and that player, with their coins and their items, is out of reach.
E-mail sign-in fixes that with a magic code and no password:
- The player types an e-mail address in your game.
- GameCoin sends a six-digit code to that address.
- The player types the code. Either the address is linked to their player, or, if it was already linked to a player on another device, they get that player back.
It is for the anonymous players of a game without a backend (the hosted players of POST /client/players). A player that your own backend creates with ext:<your id> already has an identity: yours. The routes answer 403 FORBIDDEN for those players.
Note
The browser SDK (gamecoin.js 1.1) has a method for each route, and keeps the token and the secret for you: start there. The sections after it describe the routes themselves, for a game that calls them with fetch. The dashboard is in French for now.
With the browser SDK
Four methods map to the four routes. They take the language of the e-mail as locale ("fr", "en" or "nl"), and by default the language of your page.
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
// The player is playing and links an address (or moves to the player already linked to it).
await gc.requestEmailCode("alice@example.com", { locale: "en" });
const { player, merged, switched } = await gc.verifyEmailCode("alice@example.com", "042917");
// Or: a new device, and the player wants the player of an address back.
await gc.signInWithEmail("alice@example.com");
await gc.verifySignIn("alice@example.com", "042917");The code is a string. A wrong code is the VALIDATION_FAILED error with details.fieldErrors.code = ["CODE_INVALID"], whatever the reason. When the player changes (switched: true), the SDK stores the new secret, reads the new wallet and fires a change event. See the SDK reference for the details, and warn your players before they switch: this device forgets the secret of the player it had before.
The two paths
| The player | Routes | Result |
|---|---|---|
| Is playing with a player token (this device already has a player) | POST /client/me/email, then POST /client/me/email/verify | The address is linked to this player, or the session switches to the player already linked to it |
| Has nothing on this device (new phone, cleared storage) | POST /client/sign-in/email, then POST /client/sign-in/email/verify | A token for the player linked to the address |
All four routes use your publishable key, send Idempotency-Key, and answer with the CORS headers of the client API. The two /me routes also need the player token.
Send the code
/client/me/emailAuthentication: publishable key and player token. Idempotency: required.
/client/sign-in/emailAuthentication: publishable key. Idempotency: required.
Body (JSON, any other field is refused)
| Field | Type | Required | Rules |
|---|---|---|---|
email | string | yes | A valid address of at most 254 characters. It is trimmed and lower-cased. |
locale | string | no | Language of the e-mail: fr, en or nl (a tag such as nl-BE is accepted). Without it, the Accept-Language header of the request, then French. |
curl -X POST "https://gamecoin.apilow.com/api/v1/client/me/email" \
-H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" \
-H "Idempotency-Key: email-001" \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "locale": "en"}'The answer is always the same, whether the address is known or not:
{ "sent": true, "expiresInSeconds": 600 }The e-mail carries the code and nothing else: no link to click, so there is nothing to phish. It reads the same for a player who links an address and for one who looks for a player, so it does not tell anyone whether an address is already used. The code is valid for 10 minutes and works once. A new code replaces the previous one.
Check the code
/client/me/email/verifyAuthentication: publishable key and player token. Idempotency: required.
/client/sign-in/email/verifyAuthentication: publishable key. Idempotency: required.
Body (JSON)
| Field | Type | Required | Rules |
|---|---|---|---|
email | string | yes | The same address as when you asked for the code. |
code | string | yes | The six digits. Spaces and dashes are ignored (123 456). |
curl -X POST "https://gamecoin.apilow.com/api/v1/client/me/email/verify" \
-H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" \
-H "Idempotency-Key: email-002" \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "code": "042917"}'{
"token": "gc_pt_…",
"expiresAt": "2026-10-06T14:05:12.000Z",
"player": {
"id": "68e3a4f1a1b2c3d4e5f60718",
"externalId": null,
"kind": "hosted",
"displayName": null,
"country": null,
"blocked": false,
"createdAt": "2026-10-06T13:05:12.000Z"
},
"merged": false,
"playerSecret": null
}| Field | Meaning |
|---|---|
token | A player token (one hour) for the player the session now belongs to. |
player | That player. |
merged | true when the address was already linked to another player and the session switched to it. See what happens to the coins. |
playerSecret | A new secret for the player you got back, to keep like the one of POST /client/players. null when the player did not change. |
When playerSecret is not null, store it in place of the old one, with the new player.id: it is how your game asks for the next tokens (POST /client/players/token). GameCoin keeps only a fingerprint of the secret, so it cannot give the old one back: the new secret replaces it, and the other devices of that player will sign in again with a code the next time their token expires.
What happens to the coins
GameCoin never merges two wallets. A merge would let a player move coins between two players, which the economy of your game and the ledger do not allow.
| Situation | What happens |
|---|---|
| The address is new | It is linked to the current player. Nothing else changes. |
| The address is already linked to the current player | Nothing changes. |
| The address is linked to another player | The session switches to that player (merged: true). The player the device had before is left exactly as it was, with its coins and its items, and its own secret still works. |
Warn your players before they switch: the coins of the anonymous player they had on this device stay on that player.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Invalid address, unknown field, or a code that is not six digits (details.fieldErrors). |
| 400 | VALIDATION_FAILED with fieldErrors.code = ["CODE_INVALID"] | The code is wrong, expired, used up (5 tries), already used or was never asked for. One answer for all of them, on purpose. |
| 401 | UNAUTHENTICATED | Missing or invalid player token on a /me route. |
| 403 | FORBIDDEN | The player is not anonymous (it is an ext: player of your backend). |
| 403 | PLAYER_BLOCKED | The player that the address belongs to is blocked. |
| 404 | NOT_FOUND | sign-in/email/verify with a valid code, but no player is linked to that address. Only someone who holds the code can see this. |
| 409 | IDEMPOTENCY_CONFLICT | The Idempotency-Key was already used with another address or code. |
| 429 | RATE_LIMITED | Too many codes asked. Retry-After gives the seconds to wait. |
Limits and security
| Rule | Value |
|---|---|
| Life of a code | 10 minutes, one use |
| Tries per code | 5, then the code is dead (ask for a new one) |
| Codes per address, per game and environment | 3 per hour |
| Codes asked from one IP address | 10 per hour |
Codes asked by one player (/me/email) | 10 per hour |
| Checks from one IP address | 30 per 15 minutes |
- An address is linked to one player per game and per environment. The same address can belong to a player of another game, and to another one in the
liveenvironment. - Codes and addresses are stored as keyed fingerprints. GameCoin never writes an address in its logs.
- Replaying a request with the same
Idempotency-Keysends no second e-mail and does not count in the limits. A replayed check gives the same result with a fresh token. Keys are kept 24 hours on these routes. See idempotency. - IP limits need
TRUST_PROXY=truebehind the reverse proxy, like the other rate limits.
In the dashboard
In « Joueurs », a player with a verified address shows the badge « E-mail vérifié ». Addresses are never shown in full: the list and the player page show a masked form such as a***@d***.com. Receipts of a linked player are sent to the verified address.
A complete example without the SDK
The same four routes with fetch, for a game that does not use gamecoin.js.
const API = "https://gamecoin.apilow.com/api/v1";
const KEY = "gc_pk_test_…";
async function call(path, { token, body }) {
const response = await fetch(`${API}${path}`, {
method: "POST",
headers: {
"X-GameCoin-Key": KEY,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw Object.assign(new Error(data.error.code), { error: data.error });
return data;
}
// 1. Ask for a code. With a token (a player already on this device), link or find; without, sign in.
await call(token ? "/client/me/email" : "/client/sign-in/email", { token, body: { email, locale: "en" } });
// 2. The player types the code they received.
const result = await call(token ? "/client/me/email/verify" : "/client/sign-in/email/verify", { token, body: { email, code } });
// 3. Keep the new session. A new secret means another player than the one of this device.
token = result.token;
if (result.playerSecret) localStorage.setItem("gc-player", JSON.stringify({ id: result.player.id, secret: result.playerSecret }));