# 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.

**Always `https`.** A call over plain `http` is refused with `403` and an `error` giving the `https`
address, rather than redirected: a redirect would let the first request - and the player's token in
it - cross the network unencrypted every time, without anybody noticing.

**A service does nothing until it is activated.** Each one - Authenticate, Save files, Reports,
Remote config, Leaderboards, Push messages - is activated on the project's Configure page, under
Services. Until then it is not in the dashboard's list on the left, and every call to it answers
`403` with an `error` saying which service is not activated. Deactivating one refuses the game at
once and deletes nothing: activated again, it picks everything back up. Save files and leaderboard
scores belong to signed-in players, so a game that uses them activates Authenticate too.

Once Authenticate is active, 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,
  "idToken": "eyJhbGciOiJSUzI1NiIs..."
}
```

`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.
- **`idToken`** says who the player is, signed by us, for your own game server. See
  [Your server: who is this player?](#your-server-who-is-this-player). Every refresh brings a new one.

### 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.

## Your server: who is this player?

The same way Unity does it. The game hands its `idToken` to your server - in its join message, a
header, whatever your netcode uses - and your server checks it **without asking us**: the token is a
JWT signed with our private key, and the public halves are published.

Check four things, and then `sub` is the player's id:

| Check | Must be |
| --- | --- |
| Signature | RS256, by a key in `https://www.dasmaffin.com/api/mgds/v1/.well-known/jwks.json` (pick it by the token's `kid`) |
| `iss` | `https://www.dasmaffin.com/api/mgds/v1` |
| `aud` | Your project's id - so a token from somebody else's game is refused |
| `exp` | In the future. Tokens last an hour, like the access token |

The token also carries `sid` (the session), `project_id`, `sign_in_provider` (`anonymous`,
`steam`, `sbox`, `dasmaffin`) and `name` when the player has one. Cache the key set: it changes only
when we add a key, and an old key stays published for as long as tokens it signed can be alive.
There is a discovery document at `https://www.dasmaffin.com/api/mgds/v1/.well-known/openid-configuration`
for libraries that want one.

Like Unity's, a token cannot be taken back before it expires: a player who signs out is signed out of
MGDS at once, but a server holding their token believes it until its `exp`. That is the price of your
server not having to ask, and why it lasts an hour.

**C# (a dedicated server, .NET):** with the `Microsoft.IdentityModel.Protocols.OpenIdConnect` and
`Microsoft.IdentityModel.JsonWebTokens` packages, which fetch and cache the keys themselves:

```csharp
var keys = new ConfigurationManager<OpenIdConnectConfiguration>(
    "https://www.dasmaffin.com/api/mgds/v1/.well-known/openid-configuration",
    new OpenIdConnectConfigurationRetriever());

async Task<string?> WhoIs(string idToken)
{
    OpenIdConnectConfiguration config = await keys.GetConfigurationAsync();

    TokenValidationResult result = await new JsonWebTokenHandler().ValidateTokenAsync(idToken, new TokenValidationParameters
    {
        ValidIssuer = "https://www.dasmaffin.com/api/mgds/v1",
        ValidAudience = "YOUR-PROJECT-ID",
        IssuerSigningKeys = config.SigningKeys,
    });

    return result.IsValid ? (string)result.Claims["sub"] : null;   // the player's id, or not a player
}
```

**Node:** with `jose`:

```js
import { createRemoteJWKSet, jwtVerify } from 'jose';

const keys = createRemoteJWKSet(new URL('https://www.dasmaffin.com/api/mgds/v1/.well-known/jwks.json'));

const { payload } = await jwtVerify(idToken, keys, {
  issuer: 'https://www.dasmaffin.com/api/mgds/v1',
  audience: 'YOUR-PROJECT-ID',
});
// payload.sub is the player's id; jwtVerify throws for anything else
```

## 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.

## A player's own data (GDPR)

A signed-in player can take everything this project holds about them, and can delete it all. Both
are **per project**: they cover this game only, never the player's dasmaffin.com account and never
their players in other games. Offer both somewhere in your game's settings; the law gives players
the right to each, and this is how they exercise it without writing to you.

```
GET    /me/export        everything held about the player, as one JSON document
DELETE /me               the player and everything of theirs, for good -> 204
Authorization: Bearer mgds_...
```

**The export** has the player record, how they sign in (`account` is the SteamID or dasmaffin.com
account id; `null` for a device key, which is a secret), their sessions, every save file they own
with its contents, files they made and left, their reports with the conversation, their scores on
every version of every leaderboard, and any account link they started and did not finish.
Attachments of their reports are listed with a `url` they can fetch with their token:

```
GET /me/reports/{id}/attachments/{attachmentId}
```

No token, token hash or device key hash is ever in it.

**Deleting** removes, in this order:

| What | How |
| --- | --- |
| Save files | They leave every file they own. A file they were the last owner of is deleted, values and uploads included. A file others still own stays theirs, and its `createdBy` becomes `00000000-0000-0000-0000-000000000000` |
| Reports | Theirs are deleted, with attachments and the conversation |
| Leaderboards | Their scores on every board, every version, archives included |
| Account links | Any dasmaffin.com link they had started |
| Sign-in | Every way in (logins), every session, the player record |

It cannot be undone, so ask the player to confirm first. Their tokens stop working at once; an
`idToken` your own server already holds is believed until its `exp`, at most an hour. A deletion that
fails part way can be asked for again with the same token - the session goes last.

When a request reaches you some other way - an email - a service account with `players.export` or
`players.delete` does the same for any player (below).

## 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.

## Leaderboards

Boards set up on the dashboard (the project's **Leaderboards**) or by a service account. Each has an
id the game names it by, which end is the top (`desc`: highest first, `asc`: lowest first), and
what a new score does: `keepBest` keeps it only if it is better, `keepLatest` always replaces,
`aggregate` adds it to the player's total.

**Reading needs no sign-in**: anybody with the project id can read any board.

```
GET /leaderboards                                          every board and its settings
GET /leaderboards/{id}                                     one board
GET /leaderboards/{id}/scores?offset=0&limit=10            the standings, best first (limit up to 100)
GET /leaderboards/{id}/scores/players/{playerId}           one player, with their rank
GET /leaderboards/{id}/scores/players/{playerId}/range?rangeLimit=5    them and up to 5 either side
GET /leaderboards/{id}/scores/players?ids=a,b,c            several players: a friends list
```

```json
{ "version": 3, "bucket": null, "total": 1520, "offset": 0, "limit": 10,
  "results": [ { "playerId": "...", "playerName": "Ann", "rank": 0, "score": 9120, "tier": "Gold", "bucket": null,
                 "metadata": { "car": "red" }, "updatedAt": "..." } ] }
```

`rank` starts at 0 for the top, as Unity's does. Ties go to whoever got there first.

**Submitting needs a signed-in player**, and is always their own score:

```
POST /leaderboards/{id}/scores/me        { "score": 1234, "metadata": { "car": "red" } }
GET  /leaderboards/{id}/scores/me        my entry and rank
GET  /leaderboards/{id}/scores/me/range?rangeLimit=5
GET  /leaderboards/{id}/scores/me/bucket?offset=0&limit=10    my bucket's standings
Authorization: Bearer mgds_...
```

The answer is the player's entry as it now stands, with `"updated": false` when a `keepBest` board
kept their earlier, better score. `metadata` is any JSON up to 2 KB, kept with the score it came
with. No score is judged: what counts as cheating is the game's to decide. A board marked
**server only** refuses players (`403`); its scores come from the game's own server, through a
service account (below), after it has checked them.

### Buckets

A board with a bucket size groups players as they first score, that many to a bucket, and ranks each
bucket on its own: ranks, pages and ranges are all within the player's bucket. Read a bucket with
`?bucket=2`; a signed-in player who names none gets their own, and `/scores/me/bucket` gives it
directly.

### Tiers

Named score bands: Bronze from 0, Silver from 1000, Gold from 5000. Each entry carries the best tier
its score reaches - at least the cutoff on a `desc` board, at most it on an `asc` one - or `null`.

### Resets and archives

A board can reset daily, weekly or monthly at a UTC time, or by hand. A reset closes the current
version, which stays readable, and starts an empty one:

```
GET /leaderboards/{id}/versions                            { "current": { "version": 4, "startedAt": "..." }, "archived": [ { "version": 3, "startedAt", "endedAt" } ] }
GET /leaderboards/{id}/versions/{version}/scores?offset=0&limit=10
GET /leaderboards/{id}/scores/players/{playerId}?version=3
```

A score sent the moment after a reset is due lands in the new version, whether or not anybody has
looked at the board since.

## Push messages

Make every connected game run a method, the moment you press the button or at a time you set:
start maintenance, show a banner, hand out a gift. You define **triggers** on the dashboard - a name
your game has a method for, and the parameters it takes - and send them to everyone, to chosen
players, or to a channel, narrowed by a condition if you like.

### The stream

A game holds one request open and is handed each message as it is sent. It is
[Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html): plain HTTPS,
one text line per field, readable by any HTTP client in any language.

```
GET /push/stream?environment=production&channels=eu,guild-42&attributes={"platform":"windows"}
Accept: text/event-stream
Authorization: Bearer mgds_...        optional: a signed-in player also gets what is sent to them
Last-Event-ID: 41                     optional: the last message id it saw, when reconnecting
```

| Query | |
| --- | --- |
| `environment` | The remote config environment; the default one when left out |
| `channels` | Up to 20 channels it listens on, comma separated: letters, digits, `_ - . :` |
| `attributes` | A JSON object, URL-encoded, up to 4096 characters: what conditions are checked against, as for remote config |
| `lastEventId` | The same as the `Last-Event-ID` header, for a client that cannot set headers |

Without a token anybody with the project id may connect, and gets what is sent to everyone. A
token that does not work is refused with `401`, rather than streaming as nobody: refresh it. When
the site holds as many streams as it can, it answers `503` with `Retry-After`.

What comes down it:

```
retry: 3000

event: mgds.connected
data: {"connection":"6f1c...","playerId":null,"environment":"production","channels":["eu"]}

id: 42
event: MaintenanceWarning
data: {"id":42,"trigger":"MaintenanceWarning","arguments":{"message":"Back in 20 minutes","minutes":20},"sentAt":"...","expiresAt":"...","delivery":"sticky"}

: ping

event: mgds.recalled
data: {"id":42,"trigger":"MaintenanceWarning"}

event: mgds.reconnect
data: {"reason":"the token expires; refresh it and connect again"}
```

- A message's event name is its trigger; its `data` is one line of JSON with the typed `arguments`.
- `mgds.connected` is first, with the connection id for acknowledging.
- `mgds.recalled`: a message that went out was recalled. Undo it if the game can (hide the banner).
- `: ping` every 20 seconds while it is quiet. Ignore it; it keeps proxies from closing the line.
- `mgds.reconnect`: the stream ends on purpose - when a signed-in player's token expires, and at the
  latest after an hour. Connect again, with the last id.

Names starting `mgds.` are MGDS's own; a trigger can never have one.

### Catching up, sticky and live

**Reconnect with the last id you saw** (the `Last-Event-ID` header, which every SSE client sends by
itself) and you are handed every message for you after it that still stands, before anything new.
A dropped connection, a server restart or a deploy loses nothing.

Every message is one of two kinds, chosen when it is sent:

- **Sticky**: a game that connects while it lasts gets it too, even without a last id. "Maintenance
  at 20:00" reaches a game started at 19:55.
- **Live**: only the games connected when it goes out, and those catching up with a last id. "Double
  XP starts now" is not news an hour later.

A signed-in player is not handed the same sticky message twice, on any device. A game nobody is
signed in to gets the sticky ones again each time it connects without a last id: keep the last id
and send it. Delivery is at least once; a message's `id` never repeats, so skip one you have seen.

A message stops being handed out when it **expires** (an hour after it goes out, unless the sender
says otherwise, at most 30 days) or is **recalled**.

### Who gets it

- **Everyone** connected to the environment. Untick "signed-out games too" for signed-in players only.
- **Chosen players**, by player id, up to 1000. Only their own signed-in games get it.
- **A channel**: every game that listed it in `channels`. A guild, a match, a region. To join one,
  reconnect with the new list.
- **A condition**, on top of any of those: the same language as remote config rules, on the
  `attributes` the game connected with. `platform == "windows" && app.version < "1.2"`.

### Acknowledging

When the game has run a message, it can say so. The dashboard counts it.

```
POST /push/messages/{id}/ack     { "connection": "6f1c..." }       -> 204
```

Or, for a signed-in player, with their token and no body - for a message any of their games was handed.

### A player's history

```
GET /me/push?limit=50&before={id}       Authorization: Bearer mgds_...
-> { "results": [ { "id", "trigger", "arguments", "sentAt", "expiresAt", "delivery", "receivedAt", "acknowledgedAt", "recalledAt" } ] }
```

Every message the player's games were handed, newest first: for an inbox, or after a reinstall.

### On the dashboard

Push messages lists the triggers (Add trigger, the pen to change or delete one), a Send button on
each with the arguments, who gets it, when and for how long, and every message sent: its status
(`scheduled`, `live`, `expired`, `recalled`, `cancelled`), how many games were connected when it went
out, how many got it since, and how many said they ran it. A scheduled message can be cancelled, and
a live one recalled.

## 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 |
| `leaderboards.manage` | `PUT /leaderboards/{id}`: create a board or change its settings and tiers |
| `leaderboards.reset` | `POST /leaderboards/{id}/reset` |
| `leaderboards.delete` | `DELETE /leaderboards/{id}`, with every version and score |
| `leaderboards.submit` | `POST /leaderboards/{id}/scores/players/{playerId}`, on server-only boards too |
| `leaderboards.scores.delete` | `DELETE /leaderboards/{id}/scores/players/{playerId}?version=` |
| `players.export` | `GET /players/{playerId}/export`: the same document a player gets from `/me/export` |
| `players.delete` | `DELETE /players/{playerId}`: the same deletion as `/me`, for good |
| `push.triggers` | `PUT /push/triggers/{name}`, `DELETE /push/triggers/{name}` |
| `push.send` | `POST /push/messages`, now or scheduled |
| `push.recall` | `DELETE /push/messages/{id}`: recall a message that went out, or cancel a scheduled one |
| `push.read` | `GET /push/triggers`, `GET /push/messages`, `GET /push/messages/{id}` |

### 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 }
GET    /players/{playerId}/export       everything about them (players.export)
DELETE /players/{playerId}              them and everything of theirs, for good (players.delete)
```

```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.

### Leaderboards

```
PUT    /leaderboards/{id}                                  create, or change what is sent
DELETE /leaderboards/{id}
POST   /leaderboards/{id}/reset                            archive now and start an empty version
POST   /leaderboards/{id}/scores/players/{playerId}        { "score": 1234, "metadata": { ... } }
DELETE /leaderboards/{id}/scores/players/{playerId}        the current version, or ?version=3
```

A board's settings, any left out stay as they are:

```json
{ "name": "Weekly high scores", "sortOrder": "desc", "updateType": "keepBest", "serverOnly": false, "bucketSize": 50,
  "reset": "weekly", "resetAt": "00:00", "resetWeekday": 1, "resetDay": 1,
  "tiers": [ { "name": "Bronze", "cutoff": 0 }, { "name": "Gold", "cutoff": 5000 } ] }
```

`reset` is `never`, `daily`, `weekly` or `monthly`; `resetAt` a UTC time; `resetWeekday` 0 (Sunday)
to 6; `resetDay` 1 to 28. `tiers`, when sent, replaces the board's. A score submitted for a player
follows the board's update rule exactly as the player's own would.

### Push messages

```
GET    /push/triggers
PUT    /push/triggers/{name}       { "description": "...", "parameters": [ { "name": "message", "type": "string", "required": true, "description": "..." } ] }
DELETE /push/triggers/{name}       with every message sent with it; scheduled ones are cancelled
POST   /push/messages              send or schedule -> 201, the message
GET    /push/messages?environment=&status=&limit=50&before={id}
GET    /push/messages/{id}
DELETE /push/messages/{id}         recall, or cancel if scheduled -> the message; 409 when it is already over
```

A parameter's `type` is `string`, `bool`, `int`, `long`, `float` or `json`. A trigger's name is
fixed once made: `PUT` to an existing one changes its description and parameters.

```json
{ "trigger": "MaintenanceWarning", "arguments": { "message": "Back in 20 minutes", "minutes": 20 },
  "environment": "production", "players": ["8c0e..."], "condition": "platform == \"windows\"",
  "signedOut": true, "delivery": "sticky", "sendAt": "2026-11-07T19:40:00Z", "lastsSeconds": 3600 }
```

Send to `players`, or to a `channel` (`"channel": "guild-42"`), or neither for everyone. Everything
but `trigger` may be left out: the default environment, everyone, no condition, signed-out games too,
sticky, now, an hour. `expiresAt` may stand in for `lastsSeconds`. Arguments are checked against the
trigger: every required one given, none it does not have, each of its type. A message describes
itself with `status`, `connected` (games it reached as it went out), `delivered` and `acknowledged`.

## 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 call was made over plain `http`, the service is not activated for the project (see the top of this page), 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; }
```

### Push messages in Unity (C#)

```csharp
// Reads the stream as it arrives, line by line.
class PushStream : DownloadHandlerScript
{
    readonly System.Text.StringBuilder line = new();
    string name, data;
    public long LastId;
    public System.Action<string, string> OnEvent;   // event name (the trigger), data JSON

    public PushStream() : base(new byte[4096]) { }

    protected override bool ReceiveData(byte[] bytes, int length)
    {
        foreach (char c in System.Text.Encoding.UTF8.GetString(bytes, 0, length))
        {
            if (c != '\n') { line.Append(c); continue; }
            string l = line.ToString().TrimEnd('\r'); line.Clear();
            if (l.Length == 0) { if (data != null) OnEvent?.Invoke(name ?? "message", data); name = data = null; }
            else if (l.StartsWith("event: ")) name = l[7..];
            else if (l.StartsWith("data: ")) data = l[6..];
            else if (l.StartsWith("id: ")) LastId = long.Parse(l[4..]);
        }
        return true;
    }
}

IEnumerator Listen(string token)
{
    var stream = new PushStream { OnEvent = (trigger, json) => Debug.Log($"{trigger}: {json}") };
    while (true)
    {
        using var req = new UnityWebRequest(Api + "/push/stream", "GET") { downloadHandler = stream, disposeDownloadHandlerOnDispose = false };
        req.SetRequestHeader("Accept", "text/event-stream");
        if (token != null) req.SetRequestHeader("Authorization", "Bearer " + token);
        if (stream.LastId > 0) req.SetRequestHeader("Last-Event-ID", stream.LastId.ToString());
        yield return req.SendWebRequest();
        yield return new WaitForSeconds(3);   // ended or dropped: connect again, from the last id
    }
}
```

On WebGL, `UnityWebRequest` does not stream; use the browser's `EventSource` through a `.jslib`.

### Push messages in Lua

With [lua-http](https://github.com/daurnimator/lua-http):

```lua
local request = require "http.request"

-- Listens until the stream ends; returns the last id, to call it again with.
local function listen(api, last_id, on_event)
  local req = request.new_from_uri(api .. "/push/stream")
  req.headers:upsert("accept", "text/event-stream")
  if last_id then req.headers:upsert("last-event-id", tostring(last_id)) end
  local headers, stream = assert(req:go())
  local name, data
  while true do
    local line = stream:get_body_until("\n", true, false)
    if not line then return last_id end
    line = line:gsub("\r$", "")
    if line == "" then
      if data then on_event(name or "message", data) end
      name, data = nil, nil
    elseif line:sub(1, 7) == "event: " then name = line:sub(8)
    elseif line:sub(1, 6) == "data: " then data = line:sub(7)
    elseif line:sub(1, 4) == "id: " then last_id = tonumber(line:sub(5))
    end
  end
end
```

### 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.
```
