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

## 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](/docs/sdk/browser) 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](/docs/guides/no-backend-game) (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](/docs/sdk/browser#e-mail-sign-in) (`gamecoin.js` 1.1) has a method for each route, and keeps the token and the secret for you: [start there](#with-the-browser-sdk). 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](/docs/sdk/browser#e-mail-sign-in) 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](/docs/api/client). The two `/me` routes also need the player token.

## Send the code

```endpoint
POST /client/me/email
```

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

```endpoint
POST /client/sign-in/email
```

**Authentication:** 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. |

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

```endpoint
POST /client/me/email/verify
```

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

```endpoint
POST /client/sign-in/email/verify
```

**Authentication:** 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`). |

```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
}
```

| 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](#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 `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](/docs/concepts/idempotency).
- IP limits need `TRUST_PROXY=true` behind the reverse proxy, like the other [rate limits](/docs/concepts/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`.

```javascript title="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 }));
```
