Skip to content
Skip the menu

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.

View as Markdown

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:

  1. The player types an e-mail address in your game.
  2. GameCoin sends a six-digit code to that address.
  3. 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.

JavaScript
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 playerRoutesResult
Is playing with a player token (this device already has a player)POST /client/me/email, then POST /client/me/email/verifyThe 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/verifyA 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

POST/client/me/email

Authentication: publishable key and player token. Idempotency: required.

POST/client/sign-in/email

Authentication: publishable key. Idempotency: required.

Body (JSON, any other field is refused)

FieldTypeRequiredRules
emailstringyesA valid address of at most 254 characters. It is trimmed and lower-cased.
localestringnoLanguage 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.
Bash
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:

JSON
{ "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

POST/client/me/email/verify

Authentication: publishable key and player token. Idempotency: required.

POST/client/sign-in/email/verify

Authentication: publishable key. Idempotency: required.

Body (JSON)

FieldTypeRequiredRules
emailstringyesThe same address as when you asked for the code.
codestringyesThe six digits. Spaces and dashes are ignored (123 456).
Bash
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"}'
JSON
{
  "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
}
FieldMeaning
tokenA player token (one hour) for the player the session now belongs to.
playerThat player.
mergedtrue when the address was already linked to another player and the session switched to it. See what happens to the coins.
playerSecretA 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.

SituationWhat happens
The address is newIt is linked to the current player. Nothing else changes.
The address is already linked to the current playerNothing changes.
The address is linked to another playerThe 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

StatusCodeWhen
400VALIDATION_FAILEDInvalid address, unknown field, or a code that is not six digits (details.fieldErrors).
400VALIDATION_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.
401UNAUTHENTICATEDMissing or invalid player token on a /me route.
403FORBIDDENThe player is not anonymous (it is an ext: player of your backend).
403PLAYER_BLOCKEDThe player that the address belongs to is blocked.
404NOT_FOUNDsign-in/email/verify with a valid code, but no player is linked to that address. Only someone who holds the code can see this.
409IDEMPOTENCY_CONFLICTThe Idempotency-Key was already used with another address or code.
429RATE_LIMITEDToo many codes asked. Retry-After gives the seconds to wait.

Limits and security

RuleValue
Life of a code10 minutes, one use
Tries per code5, then the code is dead (ask for a new one)
Codes per address, per game and environment3 per hour
Codes asked from one IP address10 per hour
Codes asked by one player (/me/email)10 per hour
Checks from one IP address30 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 live environment.
  • Codes and addresses are stored as keyed fingerprints. GameCoin never writes an address in its logs.
  • Replaying a request with the same Idempotency-Key sends 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=true behind 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.

email-sign-in.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 }));