# MGDS API

Maffin's Gamedev Services: backend services for games, in any engine. Plain HTTPS and JSON, no
SDK needed. Every call is made under one project:

```
https://www.dasmaffin.com/api/mgds/v1/projects/{projectId}
```

`projectId` is the GUID on the project's Configure page. Anonymous sign-in always works. Steam and
dasmaffin.com are identity providers the project adds on its Authenticate page; one it has not
added answers `403`.

## Signing a player in

Every way in ends with the same answer:

```json
{
  "playerId": "6f0c1c9e-0b5e-4c8f-9a0e-3f1d2c4b5a69",
  "sessionToken": "mgds_...",
  "expiresAt": "2026-10-24T13:00:00Z",
  "refreshToken": "mgdsr_...",
  "refreshExpiresAt": "2027-01-22T12:00:00Z",
  "displayName": "Maffin",
  "created": false,
  "deviceKey": null
}
```

`201` when the player was just made, `200` when they already existed. Signing in again never makes
somebody new: it is the same player, with a new session.

- **`sessionToken`** is the access token. Send it with every call as
  `Authorization: Bearer <sessionToken>`. It works for **one hour** (`expiresAt`).
- **`refreshToken`** gets the next pair. Keep it - after a restart too - and use it instead of
  signing in again. It is single use.

### Staying signed in

```
POST /auth/refresh
{ "refreshToken": "mgdsr_..." }
```

Answers exactly like signing in: a new access token and a new refresh token. Throw the old refresh
token away; it has just been retired. Refresh when a call answers `401`, or shortly before
`expiresAt`. The session lasts as long as it is refreshed at least every **90 days**
(`refreshExpiresAt`), so somebody who plays regularly is never asked to sign in again.

**A refresh token is good once.** If one that was already traded in turns up again, two copies of
it exist and one of them is not the player's - so MGDS ends that whole session, for both, and the
player signs in again. A stolen token is therefore noticed the next time either side refreshes,
instead of working quietly for months. The one exception is a retry: the same refresh token again
within 30 seconds (an answer lost on the way back) is answered with a fresh pair, and the pair it
replaced stops working.

When `/auth/refresh` answers `401`, sign in again.

Tokens are bearer credentials: whoever holds one is that player. Keep the refresh token where the
game keeps its other private data, and the access token in memory.

### Anonymous

```
POST /auth/anonymous
{ }
```

Makes a new player and returns a `deviceKey` with the session. **Store the device key** on the
device; it is the only way back to this player, and it is shown once. Next time:

```
POST /auth/anonymous
{ "deviceKey": "..." }
```

`401` if the key is unknown.

### Steam

Ask Steam for a ticket for the identity `mgds` (`ISteamUser::GetAuthTicketForWebApi("mgds")`), wait
for the ticket callback, and send its bytes as hex:

```
POST /auth/steam
{ "ticket": "140000006a..." }
```

`401` if Steam does not accept it. Needs Steam added as a provider on the project's Authenticate
page, with its app id and publisher key.

### s&box

For a game made in s&box, which is not a Steam app of its own and so has no app id or publisher key.
The game asks s&box for a token and sends it with the player's SteamID:

```csharp
var token = await Sandbox.Services.Auth.GetToken( "mgds" );
```

```
POST /auth/sbox
{ "steamId": "76561198000000001", "token": "..." }
```

MGDS asks Facepunch whether the token is real and belongs to that SteamID; `401` if not. The token is
valid for two minutes, so ask for it just before signing in. Needs s&box added as a provider on the
project's Authenticate page; there is nothing to set up.

Steam and s&box both prove a SteamID, so somebody who plays both the s&box version and a Steam
build of the same game is one player, with a login for each: `logins` lists `"steam"` and `"sbox"`
separately, so which way a player came in is always known.

Facepunch's check does not include the service name the token was asked for, so a token a player
gave another s&box game's backend could be replayed here within those two minutes. That is how s&box
tokens work.

### dasmaffin.com account

The player signs in in their browser; the game waits for the result.

1. Start it:

   ```
   POST /auth/dasmaffin
   { }
   ```

   ```json
   { "requestId": "...", "pollToken": "...", "url": "https://www.dasmaffin.com/Mgds/SignIn/...",
     "expiresAt": "...", "pollEverySeconds": 2 }
   ```

2. Open `url` in the player's browser (or the Steam overlay's browser).
3. Every `pollEverySeconds`, ask:

   ```
   POST /auth/dasmaffin/poll
   { "requestId": "...", "pollToken": "..." }
   ```

   | Code | Meaning |
   | --- | --- |
   | `202` | Not yet. Ask again. |
   | `200` / `201` | Signed in: the session, as above. Collected once. |
   | `403` | They chose not to allow it. |
   | `410` | Expired (10 minutes), or already collected. Start again. |

The game never sees the player's dasmaffin.com password or tokens: MGDS signs them in through the
site's own sign-in for other programs and hands the game an MGDS session.

## The signed-in player

```
GET /me
Authorization: Bearer mgds_...
```

```json
{ "playerId": "...", "displayName": "Maffin", "createdAt": "...",
  "logins": ["dasmaffin"], "accessExpiresAt": "...", "sessionExpiresAt": "..." }
```

## Signing out

```
POST /auth/logout
Authorization: Bearer mgds_...
```

`204`. That token stops working.

```
POST /auth/logout/all
Authorization: Bearer mgds_...
```

`204`. Every session the player has, on every device, ends - for "sign out everywhere", or after a
token may have been stolen. The developer can do the same from the player's page in the dashboard.

## More ways in for the same player

A player can attach more ways of signing in to the player they already are, keeping their id and
their save files. This is how an anonymous player stops depending on one device: once Steam or a
dasmaffin.com account is attached, signing in with it anywhere leads back to them. The device key
keeps working too.

An account that already belongs to another player answers `409`; players are never merged.

**Steam:**

```
POST /me/logins/steam
Authorization: Bearer mgds_...
{ "ticket": "140000006a..." }
```

**s&box:**

```
POST /me/logins/sbox
Authorization: Bearer mgds_...
{ "steamId": "76561198000000001", "token": "..." }
```

**dasmaffin.com**, through the browser exactly as signing in is, but at these addresses and with the
session:

```
POST /me/logins/dasmaffin              -> { requestId, pollToken, url, expiresAt, pollEverySeconds }
POST /me/logins/dasmaffin/poll         { "requestId": "...", "pollToken": "..." }
```

The poll answers `202` until they have allowed it, then `200`; `403` if they said no, `410` once
expired or collected.

**Removing one:**

```
DELETE /me/logins/{anonymous|steam|sbox|dasmaffin}
```

Never the last one (`409`): a player nobody can sign in as would be lost.

Each of these answers with the player's ways in now, by provider: `anonymous`, `steam`, `sbox`,
`dasmaffin`.

```json
{ "playerId": "...", "logins": [ "anonymous", "steam" ] }
```

## Save files

A save file is a named set of entries that belongs to one or more players, its **owners**. Whoever
creates a file is its first owner. Every owner has full control: they change its entries, make it
public or private, add owners and remove them. A game that never adds anybody simply has files
with one owner each.

There is no deleting a file. Owners **leave** it, and when the last owner has left, the file and
everything it stored are gone. Anything finer - only the creator may change it, say - is for the
game to enforce; every file says who made it (`createdBy`).

| | Private | Public |
| --- | --- | --- |
| Read | its owners only | anyone, no sign-in needed |
| Write | its owners only | its owners only |

A private file that is not yours answers `404`, exactly like a file that does not exist.

A file is addressed by its **id**, which you get when you create it and in every list. Its **name**
(`slot1`, `party`: letters, digits, `.`, `-` and `_`, up to 128 characters) is a label: two files
may share one.

The project can cap how many files one player may have, on its Save files page. Every file counts
for every one of its owners. Being added to a file is never refused for it, so it can take a player
over the cap; they then cannot create a new file until they are under it again (`409`).

A file holds **entries**, each under a key (anything printable except `/` and `\`, up to 255
characters) and each one of four types:

| Type | Value |
| --- | --- |
| `int` | A whole number, 64-bit. |
| `string` | Any text. |
| `json` | Any JSON value, nested at most 64 deep. |
| `file` | An uploaded file. Executables are refused (by name and by content), up to 64 MB. |

Strings and JSON over 2 MB, and every uploaded file, are kept in file storage rather than the
database; nothing about reading or writing them changes. Everything kept there is encrypted, and
only ever comes back through MGDS, which checks who is asking.

### Finding files

```
GET /me/saves                    every file you own
GET /me/saves?name=slot1         only those called slot1
GET /players/{playerId}/saves    that player's files: all of them if it is you, the public ones otherwise
```

Each lists `id`, `name`, `public`, `createdBy` and `updatedAt`.

### Creating one

```
POST /me/saves
{ "name": "slot1", "public": false,
  "entries": { "level": { "type": "int", "value": 1 } } }
```

`name` is required, the rest optional. `201` with the whole file, including its `id`.

### Reading one

```
GET /saves/{id}
Authorization: Bearer mgds_...          (only needed for a private file)
```

The whole file, as one object. Entries are under `entries` by their key, so a game reads
`save.entries.level` or `save.entries.inventory[3].count` directly; `types` says what each one is.

```json
{
  "id": "0b8c...", "name": "slot1", "public": false,
  "createdBy": "6f0c1c9e-...", "owners": [ "6f0c1c9e-..." ],
  "createdAt": "...", "updatedAt": "...",
  "entries": {
    "level": 12,
    "name": "Maffin",
    "inventory": [ { "item": "sword", "count": 1 } ],
    "avatar": { "url": "https://www.dasmaffin.com/api/mgds/v1/projects/.../saves/0b8c.../files/avatar",
                "name": "avatar.png", "bytes": 48213, "mime": "image/png" }
  },
  "types": { "level": "int", "name": "string", "inventory": "json", "avatar": "file" }
}
```

A file entry's `url` downloads it, under the same rule as the file. It always arrives as a download.

### Writing

Owners only, with their session.

**Several things at once** - name, visibility, entries, removals. All of it is saved, or none of it:

```
PUT /saves/{id}
{
  "name": "slot-one",
  "public": true,
  "entries": {
    "level": { "type": "int", "value": 12 },
    "inventory": { "type": "json", "value": [ { "item": "sword", "count": 1 } ] }
  },
  "remove": [ "oldKey" ]
}
```

**One entry:**

```
PUT /saves/{id}/entries/{key}
{ "type": "string", "value": "Maffin" }
```

**Part of a JSON entry**, by a [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901) path, without
sending the rest:

```
PATCH /saves/{id}/entries/{key}
{ "path": "/inventory/3/count", "value": 5 }
```

An object member that is not there yet is added; an array index has to exist, or be `-` to append.
`{ "path": "/inventory/0", "remove": true }` removes it. In a key, `~` is written `~0` and `/` is `~1`.

**A file entry**, as `multipart/form-data` with the file in a field called `file`:

```
PUT /saves/{id}/files/{key}
```

`415` for an executable, `413` over 64 MB.

**Removing an entry:**

```
DELETE /saves/{id}/entries/{key}
```

### Owners

```
GET    /saves/{id}/owners               who owns it
PUT    /saves/{id}/owners/{playerId}    add a player of this project
DELETE /saves/{id}/owners/{playerId}    remove one - your own id is how you leave
```

Owners only. Removing the last owner removes the file.

## Reports

A player tells you something went wrong, or has an idea, from inside the game. **No sign-in
needed**: a game cannot ask somebody to sign in before telling you it crashed. When the player is
signed in, send their session token as usual and the report is theirs - only then can they read
your answers in the game. Nothing in the form can say who sent it; only the token can.

```
POST /reports
Authorization: Bearer mgds_...        (optional)
Content-Type: multipart/form-data
```

| Field | | Meaning |
| --- | --- | --- |
| `summary` | **required** | One line. Up to 300 characters |
| `description` | **required** | What happened, in their words. Up to 8,000 |
| `kind` | optional | `Bug`, `Idea` or `Other`. Anything else is `Other` |
| `build` | optional | Your version string |
| `scene` | optional | Where they were: a scene, a screen, a level |
| `playerName` | optional | A name, when nobody is signed in. A signed-in player's own name is used instead |

Longer text is cut, not refused. Answers `201 { "id": 12, "playerId": "..." }`. `playerId` is
`null` when no working session came with it: the report is still taken, as nobody's, so a session
that expired a minute ago never costs you a crash report. If you want it to be theirs, refresh first.

### Attachments

**The field a file is sent under says what it is.** Each field takes as many files as you send;
repeat it for several. The file's name is only a label.

| Field | What it is | On the dashboard |
| --- | --- | --- |
| `logs` | A log: `{ "entries": [ ... ] }` as below, or plain text | A table you can filter by level, each line's stack folded under it |
| `images` | PNG, JPEG, GIF or WebP | Shown |
| `videos` | MP4 or WebM | Played |
| `graphs` | Measurements over time, in the [charts](/docs/reports/charts) format | Plotted |
| `info` | A JSON object of facts: the device, the settings, anything | A table, nested names joined with dots |
| anything else | A file | A download |

A log made of entries:

```json
{ "entries": [
  { "frame": 1520, "at": 31.4, "level": "Error", "message": "NullReferenceException: ...", "stack": "at Map.Open () ..." },
  { "frame": 1521, "at": 31.4, "level": "Warning", "message": "Retrying" }
] }
```

`level` is Unity's: `Log`, `Warning`, `Error`, `Exception`, `Assert`. `frame`, `at` (seconds) and
`stack` are optional. Info:

```json
{ "device": { "model": "Pixel 7a", "os": "Android 15", "memoryMb": 8192 }, "language": "en" }
```

Pictures and videos of any other type are kept and offered as a download, not shown: what the game
says a file is cannot be trusted to show safely. Executables are refused. Set
`Content-Encoding: gzip` on a part and it is unpacked before it is kept.

One report carries up to **64 MB**, unpacked. Attachments over 2 MB are kept on our file storage,
encrypted. **12 reports an hour** per signed-in player, or per address for everybody else.

### Answers, in the game

A signed-in player's own reports, and the conversation on them:

```
GET  /me/reports                      { "unread": 1, "reports": [ { "id", "kind", "summary", "receivedAt", "open", "unread", "messages" } ] }
GET  /me/reports/{id}                 the report and its messages; this is what marks your answers read
POST /me/reports/{id}/messages        { "body": "Still happens on 1.2.4" }
POST /me/reports/{id}/close
```

```json
{ "id": 12, "kind": "Bug", "summary": "...", "description": "...", "receivedAt": "...", "closedAt": null, "open": true,
  "messages": [ { "fromDeveloper": true, "body": "Which device?", "writtenAt": "..." } ] }
```

`unread` counts your answers written after they last opened it, which is what a badge wants. A
closed report takes no more messages (`409`) until you open it again on the dashboard. Text only:
nothing here hands attachments back.

### On the dashboard

Under the project's **Reports**: the queue, each report with everything it carried, your answers,
close and open again, delete. Its settings say how many days reports are kept (or until you delete
them) and whether you are emailed when one arrives.

## Remote config

Values your game fetches when it starts, changed on the dashboard (the project's **Remote config**)
without shipping a build. The same types as Unity's - `string`, `bool`, `int`, `long`, `float`,
`json` - plus `image` and `video`. **No sign-in needed**: anybody with the project id can fetch.

```
GET  /config?environment=production&userId=...
POST /config
{ "environment": "production", "userId": "install-or-player-id", "attributes": { "platform": "Android", "app": { "version": "1.2.0" }, "user": { "level": 12 } } }
```

`environment` is optional; without it you get the project's default one. An unknown one is a `404`.
`attributes` is whatever your rules match on, and only `POST` carries it. `userId` decides who falls
inside a rule rolled out to a share of players; a signed-in player's session token, when sent, is
used instead of it.

```json
{
  "environment": "production",
  "configs": {
    "speed": 1.25,
    "motd": "Hello",
    "shop": { "items": [1, 2] },
    "banner": { "url": "https://www.dasmaffin.com/api/mgds/v1/projects/.../config/media/...", "contentType": "image/png",
                "compression": "none", "bytes": 48213, "fileName": "banner.png", "width": 1280, "height": 400, "seconds": null }
  },
  "types": { "speed": "float", "motd": "string", "shop": "json", "banner": "image" },
  "rules": [ { "id": "...", "name": "Android" } ]
}
```

`rules` lists the rules that changed something for this player. Nothing set up yet is an empty
`configs`, not an error.

### Pictures and videos

A picture is PNG, JPEG, WebP or GIF; a video MP4 or WebM. Each comes as an object: fetch `url` (no
sign-in, and cache it for good: an address never changes what it holds). `compression` says how the
bytes are packed:

| `compression` | What you download | What to do |
| --- | --- | --- |
| `none` | The file itself, as `contentType` | Use it |
| `gzip` | The file, gzipped, as `application/octet-stream` | Unpack it (`GZipStream`), then it is `contentType` |

Unity loads PNG and JPEG with `ImageConversion.LoadImage`; WebP needs a decoder. `VideoPlayer`
plays MP4 (H.264) everywhere and WebM (VP8) on most platforms.

All compressing happens in the dashboard, in your browser, before anything is uploaded: pictures can
be re-encoded as PNG, JPEG or WebP at a quality and a smaller size, videos re-recorded as WebM or MP4
at a size and bitrate (it plays the video through once to do it), and either gzipped on top, with
the resulting size shown before you upload. At most 200 MB once compressed. Files are kept on our
file server.

### Environments and rules

Each environment has its own settings: `production` exists from the start, and you add others
(`development`, `staging`). One is the default.

A rule gives some players other values for some keys. It has a **condition** over the attributes,
a **rollout** (the share of matching players, by user id; under 100% skips a game that sends no id),
a **priority** (0 to 1000, lower wins where two rules set the same key), optional start and end
times, and an on/off switch. Conditions:

```
app.version == "1.2.0" && user.level >= 10
platform in ["Android", "iOS"]
!user.isTester
"beta" in user.groups
(user.level > 50 || user.vip) && platform != "WebGL"
```

Names are paths into `attributes`; one the game did not send is `null`. Compare with
`== != < <= > >=` (numbers with numbers, text with text), join with `&& || !`, group with brackets,
and `in` looks inside a list or a piece of text. A condition that does not read is refused when it
is saved.

## Service accounts

For your own game server, build machine or tools: calls made as you, the developer, not as a
player. Make one under **MGDS > Service accounts**, add a key, and tick what it may do in each of
your projects. The key is shown once; MGDS keeps only a hash of it. Send it where a player sends
their session token:

```
Authorization: Bearer mgdsk_...
```

A key never expires. Replace one by adding a second key, moving your server over, and revoking the
first. Archiving the service account stops all of its keys until you restore it. Keep keys on your
servers only: anything shipped inside a game is readable by its players.

### Permissions

Given per project. A call without the permission it needs answers `403`.

| Permission | Allows |
| --- | --- |
| `players.read` | `GET /players`, `GET /players/{playerId}` |
| `players.sessions` | `DELETE /players/{playerId}/sessions` |
| `saves.read` | Reading any save file, private ones too: `GET /saves/{id}`, its file entries, `GET /players/{playerId}/saves` (all of them, not only the public ones) |
| `saves.write` | Everything an owner can do to any save file (writing, entries, uploads, owners), and `POST /players/{playerId}/saves` |
| `reports.read` | `GET /reports`, `GET /reports/{id}`, `GET /reports/{id}/attachments/{attachmentId}` |
| `reports.manage` | `POST /reports/{id}/messages`, `POST /reports/{id}/close` |
| `config.read` | `GET /config/environments`, `GET /config/environments/{environment}` |
| `config.write` | Every call under Remote config below that changes something |

### Who am I

Not under a project, since one key can reach several:

```
GET https://www.dasmaffin.com/api/mgds/v1/service-account
```

```json
{ "id": "...", "name": "Game server",
  "projects": [ { "id": "...", "name": "The Hole", "archived": false, "permissions": ["players.read", "saves.read"] } ] }
```

### Players

```
GET    /players?page=1                  newest first, 50 to a page
GET    /players/{playerId}
DELETE /players/{playerId}/sessions     signs them out on every device -> { "ended": 2 }
```

```json
{ "page": 1, "pages": 1, "total": 2,
  "players": [ { "id": "...", "displayName": "Maffin", "createdAt": "...", "lastSeenAt": "...", "logins": ["sbox"] } ] }
```

### Save files for a player

```
POST /players/{playerId}/saves
{ "name": "guild", "public": false, "entries": { ... } }
```

The same body and answer as `POST /me/saves`, and the file is that player's exactly as if they had
made it: they are its first owner and its `createdBy`, and the project's limit on files per player
applies. Every other save file call takes the key as it is.

### Reports

```
GET  /reports?status=open&since=2026-10-01&unreadOnly=true&player={playerId}&take=50
GET  /reports/{id}                                   with its description and messages
GET  /reports/{id}/attachments/{attachmentId}        the file, as a download
POST /reports/{id}/messages                          { "body": "Fixed in 1.2.4" } - the player sees it in the game
POST /reports/{id}/close
```

`status` is `open`, `closed` or `any` (the default); anything else is a `400`. Each report lists
its attachments with their ids and kinds (`logs`, `images`, ...). Reading through the API never
marks a report read; that belongs to whoever opens it on the dashboard, where an answer shows which
service account gave it.


### Remote config

```
GET    /config/environments                                   [{ id, name, isDefault }]
POST   /config/environments                                   { "name": "staging" }
GET    /config/environments/{environment}                     settings and rules, exactly as set up
PUT    /config/environments/{environment}/settings/{key}      { "type": "float", "value": 2.5 }
                                                              { "type": "image", "media": "<id>" }
DELETE /config/environments/{environment}/settings/{key}      and every rule's value for it
POST   /config/environments/{environment}/rules               a rule, below
PUT    /config/rules/{ruleId}                                 the same; fields left out stay
DELETE /config/rules/{ruleId}
POST   /config/media                                          multipart, below -> { "media": "<id>", ... }
```

`{environment}` is its name or id. A rule:

```json
{ "name": "Fast on Android", "condition": "platform == \"Android\"", "rollout": 100, "priority": 10, "enabled": true,
  "startsAt": "2026-11-01T00:00:00Z", "endsAt": null, "overrides": { "speed": 5.0, "banner": { "media": "<id>" } } }
```

`overrides` replaces the rule's values when it is sent, and every key in it must be a setting of
that environment, of that setting's type. Changing a setting's type drops every rule's value for it.

Media is uploaded first, already compressed, as `multipart/form-data`: the file in `file`, its
`contentType` (what it is once unpacked), and `compression` (`none` or `gzip`, checked). Optionally
`width`, `height`, `seconds`, `originalBytes` and `encoding` (a few words on what was done to it).
An upload no setting or rule uses within a day is deleted.

## Errors

Every error has a JSON body `{ "error": "..." }`.

| Code | Meaning |
| --- | --- |
| `400` | A value that is not its type, a bad name or key, or a malformed request. The `error` says which. |
| `401` | Bad credential, or not signed in. For a service account: a key that is unknown, revoked, or on an archived service account. |
| `403` | The project has not added that provider, or a service account key lacks the permission on this project. |
| `404` | No such project, or no such save file (or one that is private and not yours). |
| `409` | Over the project's limit on save files per player; leave one first. Or a message on a closed report. |
| `413` | A report over 64 MB. |
| `415` | An executable attached to a report. |
| `429` | More than 12 reports in an hour. |
| `410` | The project is archived: everything is kept, but the game cannot reach it until its developer restores it. |
| `503` | The project's database is not ready yet. |

## Examples

### Garry's Mod (Lua)

```lua
local API = "https://www.dasmaffin.com/api/mgds/v1/projects/YOUR-PROJECT-ID"

local function post(path, body, done)
    HTTP({
        url = API .. path, method = "POST",
        body = util.TableToJSON(body or {}), type = "application/json",
        success = function(code, text) done(code, util.JSONToTable(text or "") or {}) end,
        failed = function(err) print("MGDS failed: " .. err) end,
    })
end

-- Anonymous: reuse the device key if there is one.
local key = file.Read("mgds_device.txt", "DATA")
post("/auth/anonymous", { deviceKey = key }, function(code, res)
    if res.deviceKey then file.Write("mgds_device.txt", res.deviceKey) end
    print("Signed in as player " .. tostring(res.playerId))
end)
```

### s&box (C#)

```csharp
const string Api = "https://www.dasmaffin.com/api/mgds/v1/projects/YOUR-PROJECT-ID";

// A sketch: check the names against the current s&box API reference.
async Task<string> SignInAsync()
{
    var token = await Sandbox.Services.Auth.GetToken( "mgds" );
    var steamId = Game.SteamId.ToString(); // the local player's SteamID64

    var answer = await Http.RequestJsonAsync<Session>( Api + "/auth/sbox", "POST",
        Http.CreateJsonContent( new { steamId, token } ) );

    return answer.sessionToken; // keep it, and send it as Authorization: Bearer
}

record Session( string playerId, string sessionToken, string displayName, bool created );
```

### Unity (C#)

```csharp
const string Api = "https://www.dasmaffin.com/api/mgds/v1/projects/YOUR-PROJECT-ID";

IEnumerator SignInAnonymously()
{
    string key = PlayerPrefs.GetString("mgds_device", null);
    string json = key == null ? "{}" : $"{{\"deviceKey\":\"{key}\"}}";

    using var req = new UnityWebRequest(Api + "/auth/anonymous", "POST");
    req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
    req.downloadHandler = new DownloadHandlerBuffer();
    req.SetRequestHeader("Content-Type", "application/json");
    yield return req.SendWebRequest();

    var res = JsonUtility.FromJson<Session>(req.downloadHandler.text);
    if (!string.IsNullOrEmpty(res.deviceKey)) PlayerPrefs.SetString("mgds_device", res.deviceKey);
}

[System.Serializable]
class Session { public string playerId, sessionToken, expiresAt, displayName, deviceKey; public bool created; }
```

### Godot (GDScript)

```gdscript
const API = "https://www.dasmaffin.com/api/mgds/v1/projects/YOUR-PROJECT-ID"

func start_dasmaffin_sign_in() -> void:
    var http := HTTPRequest.new()
    add_child(http)
    http.request(API + "/auth/dasmaffin", ["Content-Type: application/json"], HTTPClient.METHOD_POST, "{}")
    var result = await http.request_completed
    var started = JSON.parse_string(result[3].get_string_from_utf8())
    OS.shell_open(started.url)
    # Then POST /auth/dasmaffin/poll every 2 seconds with requestId and pollToken until it answers 200 or 201.
```
