# 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](/docs/hibernation#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](/docs/builds#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](/docs/stats) |
| `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](/docs/limits#credit) |

The body is JSON:

```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](/docs/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:

1. Compute the HMAC over the raw body exactly as received, before
   parsing it.
2. Compare it with `v1` in constant time.
3. Reject requests whose `t` is far from your clock, which stops a
   captured request being replayed later. Five minutes is a reasonable
   window.

```ts
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.
