# HTTP API

> The control server API at api.irt.io/v1 for projects, keys, secrets, builds, rooms, logs and usage.

`https://api.irt.io/v1` takes and returns JSON. The dashboard, the CLI and
your backend all use it.

## Authentication

| Credential | Sent as | Used by | Acts for |
|---|---|---|---|
| Session | cookie `__Host-irt_session` | The dashboard at `app.irt.io` | Your tenant |
| Personal token | `Authorization: Bearer irt_pat_...` | The CLI, after `irt login` | Your tenant |
| API key | `Authorization: Bearer irt_key_...` | Backends and CI | One project |

```sh
curl https://api.irt.io/v1/projects/my-game/rooms \
  -H "Authorization: Bearer $IRT_API_KEY"
```

Keep API keys on your backend. Never ship one in a client.

### Signing in

- `GET /auth/github?invite=...` starts signing in with GitHub. The invite
  code is needed the first time only. The callback,
  `/auth/github/callback`, sets the session and returns to the dashboard.
- `POST /auth/logout` ends the session.
- `GET /me` returns the signed-in user, their tenant, its credit balance,
  and whether the user is an IRT admin.

### Device flow

This is how `irt login` gets a personal token:

1. `POST /auth/device` returns `{deviceCode, userCode, verificationUrl}`.
2. The user opens `https://app.irt.io/device`, signs in and enters
   `userCode`.
3. The CLI polls `POST /auth/device/token` with `{deviceCode}` until it
   returns `{token}`.

## Errors

Errors have an HTTP status and a body of this shape:

```json
{ "error": { "code": "not_found", "message": "No project named my-gmae." } }
```

`code` is a stable string to branch on, such as `not_found`, `invalid`,
`limit` or `unauthorized`. `message` is for people and may change.

## Projects

| Method | Path | Body | Does |
|---|---|---|---|
| GET | `/projects` | | Lists your projects |
| POST | `/projects` | `{slug, name}` | Creates a project |
| GET | `/projects/{p}` | | Gets a project and its settings |
| PATCH | `/projects/{p}` | `{name?, settings?}`, with only the settings to change | Updates a project |
| DELETE | `/projects/{p}` | | Deletes a project |

`{p}` is the project's slug.

### Project settings

| Setting | Default | Allowed | Meaning |
|---|---|---|---|
| `players.guests` | `true` | boolean | Clients may join with no player token |
| `players.createRooms` | `true` | boolean | Clients may create rooms and use join codes, and connecting to a room that doesn't exist creates it |
| `players.listPublic` | `true` | boolean | Clients may list active public rooms |
| `players.quickJoin` | `false` | boolean | Clients may join any public room with space, or a new one |
| `players.listPublicWithoutToken` | `false` | boolean | Anyone may list public rooms with no token, even with `players.guests` off |
| `voice.enabled` | `false` | boolean | Connections get a voice token |
| `voice.maxSpeakers` | `64` | integer 1 to 64 | A ceiling on each room's own `voice.maxSpeakers`, applied from a room's next wake; see [Proximity voice](/docs/guides/proximity-voice#turning-it-on) |
| `rooms.size` | `"standard"` | `"standard"`, `"large"`, `"extra-large"` | Room size for new rooms |
| `rooms.retention` | `"7d"` | `"none"`, `"<n>h"`, `"<n>d"` up to 365 days, `"forever"` | How long a room is kept after it hibernates; `"none"` deletes it when it empties |
| `rooms.buildUpdate` | `"hibernate"` | `"restart"`, `"hibernate"` | Running rooms move to a new build at once, or on their next hibernate |
| `rooms.rollbackOnCrashLoop` | `true` | boolean | When a newly live build crash-loops, make the build it replaced live again, once per build; see [Builds](/docs/builds#automatic-rollback) |
| `data` | none | see [Player data](/docs/guides/player-data#declaring-it) | Player data keys clients may write, and the project's collections and leaderboards; normally declared in `irt.config.ts` |
| `limits.maxActiveRooms` | `100` | integer | Spending cap: rooms active at once; never from code |
| `limits.maxRoomHoursPerDay` | `200` | integer | Spending cap: room-hours per day; never from code |
| `limits.maxSleepingConnections` | `10000` | integer, 0 to 1,000,000 | Connections the project's [sleeping rooms](/docs/hibernation#sleeping-rooms) hold in a region; never from code |
| `webhookUrl` | none | https URL | Where [webhook](/docs/webhooks) events go; never from code |

Settings go under `settings`, in the same nested shape as the table, and
`null` resets one to its default. Any other top-level field is a `400
invalid` that says so:

```sh
curl -X PATCH $IRT_API_URL/projects/my-game \
  -H "Authorization: Bearer $IRT_API_KEY" -H "Content-Type: application/json" \
  -d '{"settings": {"webhookUrl": "https://example.com/irt", "players": {"guests": false}}}'
```

A setting the live build declares in its
[`irt.config.ts`](/docs/cli#irtconfigts) can't be changed here: a
`PATCH` of it answers 409 naming that build.

A project's client config, which the client SDK needs to connect, is its
slug and its region's router: `{project, router: "wss://eu.irt.io"}`.

## Keys and secrets

| Method | Path | Body | Does |
|---|---|---|---|
| GET | `/projects/{p}/api-keys` | | Lists API keys |
| POST | `/projects/{p}/api-keys` | `{name}` | Creates an API key and returns it, once |
| DELETE | `/projects/{p}/api-keys/{id}` | | Deletes an API key |
| GET | `/projects/{p}/signing-keys` | | Lists signing keys: `{signingKeys: [{id, createdAt}]}` |
| POST | `/projects/{p}/signing-keys` | none | Creates a signing key and returns `{id, secret, createdAt}` with `201`; the secret is shown only this once |
| DELETE | `/projects/{p}/signing-keys/{id}` | | Deletes a signing key; `204` |
| POST | `/projects/{p}/webhook-secret` | | Rotates the webhook secret and returns it, once |
| GET | `/projects/{p}/secrets` | | Lists secret names |
| PUT | `/projects/{p}/secrets/{name}` | `{value}` | Sets a secret |
| DELETE | `/projects/{p}/secrets/{name}` | | Deletes a secret |

Signing keys sign [player tokens](/docs/player-tokens). Creating one
takes no request body; a body sent anyway, such as `{}`, is ignored. A
project has at most 5 signing keys, and creating a sixth answers `422`
with the code `limit`. Signing keys are managed with a login or personal
token, from the dashboard or `irt signing-keys`: an API key gets `403`.
Secrets reach room hosts as environment variables; their values are
never returned. `PATH`, `NODE_OPTIONS`, `HTTPS_PROXY`,
`NODE_USE_ENV_PROXY` and names starting with `IRT_` are reserved, and
setting one answers `400`.

## Builds

| Method | Path | Body | Does |
|---|---|---|---|
| POST | `/projects/{p}/builds` | the build tarball, `application/gzip` | Uploads a build; returns it, with `id`, `types` and `createdAt` |
| GET | `/projects/{p}/builds` | | Lists builds, newest first, with which is live |
| POST | `/projects/{p}/builds/{id}/live` | | Makes a build live |
| POST | `/projects/{p}/restart` | `{room?}` | Moves running rooms, or one, onto the live build now; returns `{rooms}` |

Making a build live switches the site at once and moves rooms to it
according to `buildUpdate`. The tarball's layout is in
[Builds and deploys](/docs/builds#whats-in-a-build).

A build's `types` are its [room types](/docs/concepts#rooms) by
name, as its manifest declares them: each with `schemaHash`, and
`schema`, `stateVersion` and `entry` for a standard type, or `native`,
`schema`, `stateVersion` and `tickRate` for a native one, and its own `size`, `maxPlayers` and `retention` when it
sets them. Making a build live returns it with `schemaChecks`, each type
in both builds by name with its
[schema check](/docs/builds#the-schema-check-at-deploy), and
`schemaCheck`, the `default` type's or `null`. A refusal, `409` with
code `schema_change` or `type_removed`, names the type in `error.type`.

## Rooms

| Method | Path | Body or query | Returns |
|---|---|---|---|
| POST | `/projects/{p}/rooms` | `{id?, type?, size?, retention?, public?, joinCode?, meta?, maxPlayers?}` | the room |
| GET | `/projects/{p}/rooms` | `?public&cursor` | active rooms |
| GET | `/projects/{p}/rooms` | `?status=all&cursor` | every room the project keeps, hibernated ones included |
| GET | `/projects/{p}/rooms/{id}` | | the room, with its status |
| DELETE | `/projects/{p}/rooms/{id}` | | |
| POST | `/projects/{p}/rooms/{id}/messages` | `{message}` | the room's reply |
| GET | `/projects/{p}/rooms/{id}/state` | `?meta=1` | the room's `room.state`; with `meta=1`, `{state, tick, tickRate, savedAt}`, where `savedAt` is when room state was last written (RFC 3339), for computing time-derived values; a room whose build uses an `@irt/room` older than `?meta=1` answers `422 room_error`, so rebuild and deploy to use it |
| GET | `/projects/{p}/rooms/{id}/profile` | | an active room's tick profile over its last second, `{window, profile}`, per phase `{count, total, p50, p95, max}` in milliseconds; `409 room_not_active` for a room that isn't active, which isn't woken for it; a build whose `@irt/room` predates it answers `422 room_error` |
| POST | `/projects/{p}/rooms/{id}/kick` | `{player}` | `204`; closes the player's connection with 4006 |
| POST | `/projects/{p}/rooms/{id}/voice/mute` | `{player, muted}` | `204`; mutes or unmutes the player's voice |
| POST | `/projects/{p}/rooms/{id}/voice/remove` | `{player}` | `204`; disconnects the player from the room's voice |

A room looks like this:

```json
{
  "id": "forest-7",
  "status": "active",
  "build": "b_01J9Z3",
  "size": "standard",
  "public": true,
  "joinCode": "K7QX2M",
  "meta": { "mode": "coop" },
  "maxPlayers": 4,
  "players": 3,
  "createdAt": "2026-11-04T10:00:00Z",
  "stateBytes": 18342
}
```

`status` is `active`, `waking` or `hibernated`. `stateBytes`, only on
`GET /projects/{p}/rooms/{id}`, is the size of the room's room state as
last saved to object storage, which trails a running room by up to 30
seconds; it's `null` before the room's first save. A room stops when its
room state passes 32 MB. Leaving out `id` on
create generates one. `type` is the room's [room type](/docs/concepts#rooms), one of
the live build's, else the type named `default`; a build without one
answers `400 invalid` naming its types. Without `size`, a room gets its
type's size, else the project's `rooms.size`, and the same for
`maxPlayers` and `retention`.

Creating a room doesn't start it. It's `hibernated`, with `build: null`,
until its first connection or API call, which starts it on the live
build like a wake: the client sees `waking` (the SDK's `onWaking` and
`waking` event), and the room runs `onCreate`, not `onWake`, since it
has no room state yet. `build` is set from then on. A room created by
connecting to an ID that doesn't exist starts the same way. `maxPlayers` caps the room's connections, from 1
to its size's connection limit; connecting past it closes with 4003. It's
`null` when the room is capped only by its size. `retention` gives the
room a retention of its own, with the same values as `rooms.retention`,
which wins over the project's: a game can keep its persistent rooms
`"forever"` and let throwaway ones go after a day. Rooms clients create
always follow the project's.

A list is `{rooms, cursor}`; pass `cursor` back for the next page until
it's `null`. With `?status=all` it lists every room the project keeps,
sorted by ID, 500 a page: active rooms as above, and hibernated ones as
`{id, status: "hibernated", players: 0}`. `public` can't be combined with
`status=all`. A room deleted through this API leaves the list at once.
A room created moments ago by a client, or deleted by its retention, may
be missing from it, or still in it, for a few seconds. Sending a message or reading state wakes a
hibernated room, so the call may take as long as a wake, and the room
then stays up for 30 seconds of room-hours before it hibernates again.
There's no read of a hibernated room's stored state yet. To show a
room's data publicly without waking it on every view, keep a copy in a
backend of your own: read the room while `GET /projects/{p}/rooms/{id}`
says it's `active`, and between reads compute what time changes from
`?meta=1`'s `tick` and `savedAt`. A site alone can't do it, since it
can't hold an API key and `/api` on its domain is reserved. Don't
refresh on `room.hibernated`: that read wakes the room, which hibernates
and sends `room.hibernated` again.

```sh
curl -X POST https://api.irt.io/v1/projects/my-game/rooms/forest-7/messages \
  -H "Authorization: Bearer $IRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"message": {"type": "grant", "player": "u_42", "item": "lantern"}}'
```

When the room can't answer a message or a state read, the call answers
`422` with code `room_error`, and `message` says why:

```json
{ "error": { "code": "room_error", "message": "onRequest threw: unknown request" } }
```

- `onRequest threw: ...` carries the message of the error your
  `onRequest` threw or rejected with. The room logs it at level `error`.
- `onRequest rejected: ...` carries the message of a `CallRejected`
  your `onRequest` threw, for an expected refusal such as a move that
  breaks a rule. The room doesn't log it.
- `onRequest` may return a promise; the API waits for it.
- `unsupported call message` means the room's code has no `onRequest`.
- Other messages say the room hibernated or stopped before it answered,
  or didn't answer within 10 seconds. The call may or may not have run.

A room that can't be woken answers `503 unavailable`; when the cause is
in the build, such as room code that throws on load or a room SDK the
host doesn't support, `irt logs` has a line saying why. A project over its
spending limit or out of credit `429 limit`, and a router that got no
answer in time `504 timeout`.

Kick and the voice calls answer `404` when the player isn't connected,
and the voice calls `409` with code `voice_off` when the region has no
voice server. See [Moderation](/docs/moderation).

## Bans

| Method | Path | Body | Does |
|---|---|---|---|
| GET | `/projects/{p}/bans` | | Lists the bans that haven't expired |
| POST | `/projects/{p}/bans` | `{player, reason?, expiresAt?}` | Bans a player, or replaces their ban; returns the ban |
| DELETE | `/projects/{p}/bans/{player}` | | Unbans a player |

A ban is `{player, reason, expiresAt, createdAt}`; `expiresAt` is `null`
for a permanent ban, and must be in the future when given. A player ID is
1 to 134 bytes; URL-encode it in the path, so `guest:ab12` is
`guest%3Aab12`. A reason is at most 1000 bytes. A project can have 10,000
active bans; banning one more answers `422 limit`. The router closes a
banned player's connections with 4013 within a few seconds and refuses
new ones.

## Data

| Method | Path | Body | Does |
|---|---|---|---|
| POST | `/projects/{p}/data` | one operation, at most 128 KB | Runs a player data, collection or leaderboard operation as the server |

The body is an operation, such as `{"op": "player.get", "player":
"ada", "key": "save"}`; the operations are those of the
[room SDK](/docs/reference/room-sdk#player-data-collections-and-leaderboards),
named `player.get`, `player.set`, `player.increment`, `player.delete`,
`player.list`, `entry.get`, `entry.put`, `entry.list`, `entry.delete`,
`board.submit`, `board.top` and `board.around`, with their arguments as
fields and `null` meaning an optional one is left out. A backend may do
every operation, and also `player.purge`, which deletes a player's data
and every entry they own, leaderboard scores included, and answers
`{keys, entries}`, the numbers deleted: use it for a deletion request.

```sh
curl -X POST $IRT_API_URL/projects/my-game/data \
  -H "Authorization: Bearer $IRT_API_KEY" -H "Content-Type: application/json" \
  -d '{"op": "board.top", "collection": "highscores", "period": "week", "limit": 10}'
```

The answer is `{"ok": true, "result": ...}` or `{"ok": false, "error":
{"code": "E_...", "message"}}`, with `200` either way, and `503` with
code `E_UNAVAILABLE` when the data service can't be reached within 5
seconds; a write that timed out may or may not have happened, so retry
it with `version`. The codes are in [Errors](/docs/reference/errors#data-calls).

Clients reach the same operations, with a client's rights, through the
router: `POST /v1/data/{p}` on the region's router with a player or
guest token as `Authorization: Bearer`, and `POST /v1/guest` with
`{project}` for a guest token, `{token, player}`, at most 10 a minute
per address. The client SDK's `dataClient` makes these calls.

## Logs and usage

- `GET /projects/{p}/logs?since=...&follow=1` returns room host output.
  With `follow`, it's a stream of server-sent events.
- `GET /projects/{p}/usage?from=...&to=...` returns usage by day:
  room-hours by room size, traffic, voice-minutes and cost.
- `GET /projects/{p}/stats?from=...&to=...` returns each build's peak
  players and rooms, reconnects, and the errors and tick cost its rooms
  report, over a range of at most 14 days.
  `GET /projects/{p}/builds/{id}/stats` returns one build's per minute,
  over at most 24 hours. See [Stats and usage alerts](/docs/stats).
- `GET /projects/{p}/alerts` returns today's usage alerts.
