Reference
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 |
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/logoutends the session.GET /mereturns 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:
POST /auth/devicereturns{deviceCode, userCode, verificationUrl}.- The user opens
https://app.irt.io/device, signs in and entersuserCode. - The CLI polls
POST /auth/device/tokenwith{deviceCode}until it returns{token}.
Errors
Errors have an HTTP status and a body of this shape:
{ "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 |
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 |
data |
none | see Player data | 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 hold in a region; never from code |
webhookUrl |
none | https URL | Where webhook 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:
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 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. 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.
A build’s types are its room types 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, 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:
{
"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, 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.
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:
{ "error": { "code": "room_error", "message": "onRequest threw: unknown request" } }
onRequest threw: ...carries the message of the error youronRequestthrew or rejected with. The room logs it at levelerror.onRequest rejected: ...carries the message of aCallRejectedyouronRequestthrew, for an expected refusal such as a move that breaks a rule. The room doesn’t log it.onRequestmay return a promise; the API waits for it.unsupported call messagemeans the room’s code has noonRequest.- 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.
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,
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.
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.
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=1returns room host output. Withfollow, 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}/statsreturns one build’s per minute, over at most 24 hours. See Stats and usage alerts.GET /projects/{p}/alertsreturns today’s usage alerts.