Build and deploy
Webhooks
Events IRT sends to your backend when rooms and players change, and how to verify them.
Set a project’s webhookUrl and IRT sends it an HTTP POST for each
event in the project’s rooms, and when the project nears a spending cap.
Set it in the dashboard, with irt webhooks set <url>, or with
PATCH /projects/{p} and {"settings": {"webhookUrl": "https://..."}}.
The URL must be https. A local or staging stack whose control server
sets webhooks.allow_private_addresses also accepts http and private
addresses, so a backend on the same machine can receive events.
Events
| Type | Sent when |
|---|---|
room.created |
A room is created; data is {public, joinCode, retention}, where retention is the room’s own retention from POST /rooms, or null when it follows the project’s rooms.retention |
room.active |
A room has started or woken and is running; data is {generation} |
room.hibernated |
A room has hibernated; data is {generation} |
room.restored |
A room woke on a build older than its room state, after a rollback, and started from its room state from before the migration; data is {generation, build, fromVersion, version} |
room.deleted |
A room is deleted; data is {reason}: "api" when deleted through the API, the CLI or the dashboard, "expired" when its retention passed while hibernated, and "retention" when it emptied with retention "none" |
player.joined |
A player’s connection joins a room; data is {player, connection} |
player.left |
A player’s connection leaves a room; data is {player, connection, code}, with the close code |
build.rolled_back |
The live build crash-looped and the build it replaced went live again; room is null and data is {build, to, reason, crashes}. See Automatic rollback |
usage.alert |
The project passed 80% of a spending cap, at most once per cap per UTC day; room is null and data is {limit, used, max, day}, with limit maxRoomHoursPerDay or maxActiveRooms. See Stats and usage alerts |
billing.low_balance |
Your account’s credit dropped below the low-balance threshold; sent to every project, once per drop, with room null and data {balance, threshold, graceEndsAt}. graceEndsAt is when projects stop waking rooms, if the balance is already at zero. See limits |
The body is JSON:
{
"type": "player.joined",
"project": "my-game",
"room": "forest-7",
"at": "2026-11-04T10:00:00Z",
"data": { "player": "u_42" }
}
Delivery
Each event is sent once, best effort. There are no retries, so a webhook
that is down misses events. Treat webhooks as hints and read the
HTTP API when you need the current truth, for example
GET /projects/{p}/rooms/{id} for a room’s status.
Events normally arrive well under a second after they happen. A burst
waits on your endpoint, which gets up to 32 deliveries at once, and
events held while IRT’s services catch up after an outage arrive late,
up to minutes. Order isn’t guaranteed. A room hibernates 30 seconds
after its last player.left, so a read of the room on that event finds
it running; a read that arrives late can wake it again.
Respond quickly with a 2xx; do slow work after responding.
Order
Events aren’t delivered in order. Different parts of IRT send them: the
router sends room.created, room.deleted from the API and the player
events, and the host running the room sends room.active,
room.hibernated and room.restored. So a room created and started at
once often sends room.active before room.created. Order a room’s
events by at, and by generation where they have one.
generation grows by one at each attempt to start the room, including
attempts that fail, so it can skip numbers. A room that failed to wake
four times and then woke reports room.active with a generation four
higher than its last.
Verifying a webhook
Every request has an IRT-Signature header:
IRT-Signature: t=1793786400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
v1 is the hex HMAC-SHA256 of t, a dot and the raw request body, keyed
with the project’s webhook secret. To verify:
- Compute the HMAC over the raw body exactly as received, before parsing it.
- Compare it with
v1in constant time. - Reject requests whose
tis far from your clock, which stops a captured request being replayed later. Five minutes is a reasonable window.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(header.split(",").map((kv) => kv.split("=", 2) as [string, string]));
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
The webhook secret
Rotate the secret in the dashboard, with irt webhooks rotate-secret
or with POST /projects/{p}/webhook-secret. The new secret is shown once and
signs every event from then on, so update your backend straight away.