# Webhooks

A project's webhook is one signed HTTP request per event, so your backend learns a quota is close
or crossed without polling for it.

## The endpoint

One `https` URL per project. Set it in the console's Webhooks panel, or with a secret key holding
`webhooks:manage` (an organization key, or a console sign-in with admin access to the project, also
work):

```
PUT /v1/project/webhooks
Authorization: Bearer bsk_sk_...
content-type: application/json

{"url":"https://example.com/hooks/bitskiff"}
```

Every `PUT` answers with the signing secret, and no `GET` ever does:

```
200 OK
{"url":"https://example.com/hooks/bitskiff","secret":"<64 hex characters>","kid":"a1b2c3d4"}
```

`GET /v1/project/webhooks` answers the same shape with `secret` always empty. If you lose the
secret, call `PUT` again with the same URL: the value you get back is derived from your project, so
re-reading it does not disturb a receiver that already has it. Setting the URL to an empty string
clears it.

## The URL

A URL that fails any of these is refused, and never dialed:

| Rule | What is required |
|---|---|
| Scheme | `https` only |
| Credentials | none in the URL |
| Fragment | none |
| Host | resolves to a public IP address -- no loopback, link-local, private or carrier-grade-NAT range |
| Redirects | never followed; a `3xx` response is a delivery failure, not a hop |
| Timeout | 5 s, connect plus read |

The host check runs again on every delivery, not only when you set the URL, so a name that
resolved publicly when you saved it cannot later be pointed somewhere internal.

## Delivery verification

Every delivery carries `X-Bitskiff-Signature: t=<unix seconds>,v1=<hex>`, where `<hex>` is an
HMAC-SHA256 over `<t>.<raw body>` under your signing secret, and
`X-Bitskiff-Signature-Kid`, the id of the secret that signed it. Recompute the MAC over the exact
bytes you received, compare in constant time, and reject anything older than five minutes:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// The raw body as you received it, the `X-Bitskiff-Signature` header, and your signing secret.
function verifyWebhook(rawBody, header, secretHex) {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
  if (match === null) return false;
  const t = Number(match[1]);                     // the pattern matched, so both groups are there
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac('sha256', Buffer.from(secretHex, 'hex'))
    .update(`${t}.${rawBody}`)
    .digest();
  const given = Buffer.from(match[2], 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

// The raw body as you received it, the `X-Bitskiff-Signature` header, and your signing secret.
function verifyWebhook(rawBody: string, header: string, secretHex: string): boolean {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
  if (match === null) return false;
  const t = Number(match[1]);                     // the pattern matched, so both groups are there
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac('sha256', Buffer.from(secretHex, 'hex'))
    .update(`${t}.${rawBody}`)
    .digest();
  const given = Buffer.from(match[2], 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

Read the body as raw bytes before you parse it as JSON: the signature is over the exact bytes
sent, and a body your framework has already reformatted will not match.

## Retries

A delivery is retried for 24 hours. The attempts go at once, then after 1 minute, 5 minutes,
30 minutes, 2 hours and 6 hours, then every 6 hours to the 24-hour mark. Anything outside the
200-299 range -- a redirect included -- counts as a failure and moves to the next attempt.

Every attempt sends the same body, byte for byte, and signs it with the time of that attempt. A
retry six hours later passes the five-minute check, and its signature differs from the first
attempt's.

After the last attempt the event is dropped, and your project's audit log gets one
`webhook.delivery_failed` row naming the reason, the status and how many attempts were made.

Delivery is at-least-once. Make your handler idempotent on the delivery's `id` and expect the
occasional duplicate rather than a guarantee of exactly one.

## Events

Five events exist. Three are always delivered; two are usage records you opt into:

| Kind | Fires when | Carries |
|---|---|---|
| `limit.warning` | A metered quota crosses 80% of its ceiling for the current period | the limit's name, its `scope` (project or key) and `scopeId`, the `value` the counter reached, the `ceiling` it is measured against, `requestId`, the `orgId`, and `keyId` for a cap a key holds |
| `limit.exceeded` | The same quota reaches its ceiling | the same fields as `limit.warning` |
| `open.refused` | A host's `open` is turned away | the host's `label`, except on a paced delivery, and `metadata`, the refusal's `code` and what `clears` it, the cap that refused it, `requestId`, and a `count` of the refusals the event stands for |
| `session.ended` | A session ends, in a project whose `kinds` lists it | `SessionUsage`, the session's usage record |
| `client.gone` | A client leaves, in a project whose `kinds` lists it | `ClientUsage`, the client's usage record |

Each of the two quota notices fires once per limit, per scope, per period -- crossing back under
the ceiling and up again in the same period does not fire it twice.

### Usage records

`session.ended` and `client.gone` report what a session or a client used, for a live dashboard or
an alert. Neither is delivered until you add it to `kinds`:

```
PUT /v1/project/webhooks
Authorization: Bearer bsk_sk_...
content-type: application/json

{"url":"https://example.com/hooks/bitskiff","kinds":{"values":["session.ended"]}}
```

`kinds` only adds: the three events above are delivered whether or not you set it, an empty
`kinds` keeps you on exactly them, and adding `client.gone` later does not stop `session.ended`
from arriving. A large session sends one `client.gone` per client, so add it only once your
handler expects that volume.

To bill your own customers, read the ledger instead: [Usage-based
billing](/docs/guides/usage-based-billing/) covers the monthly read, and a Stripe sample is on the
[downloads page](/docs/downloads/).

### Refused opens

The `open.refused` event is the one failure you cannot read any other way. A host whose last session ended
cleanly and whose every `open` since has been refused has no session, so no `session.*` event
names it and it holds no row in the console's live list -- which is exactly what an unplugged
machine looks like. Pass a `label` when you open and this event tells you which installation.

It fires once per episode, not once per refusal. The episode is the label, the code and the cap
together, so a fleet parked on a revoked key is one delivery per host and then silence, not a
delivery every five minutes for as long as the fault lasts.

The episode ends when the refusal's
`resetAt` passes, after 24 hours when it names none, or the moment that label opens a session.

Two codes are the exception. The codes `rate_limited` and `concurrency_limit` are the gate pacing
your fleet rather than a fault at one host, so they arrive as one event per code per cap per 5
minutes, carrying no `label` and a `count` of what the window turned away.

The `count` field is refusals and never hosts: twelve hosts blocked on a concurrency cap and
retrying every 10 to 20 seconds make about 240 refusals in that window.

A host either of those has refused without a break for ten minutes
gets an event of its own with its label on it, so one permanently blocked installation is never
hidden behind the number.

Some failures reach no event at all. The `open.refused` event is fired where the request is
answered, so a host cut off by its own network, on dead DNS, or refused at the edge sends nothing and
reaches nothing. To your backend it looks the same as a machine that is unplugged.

Watch for those by listing what is running, not by waiting for a delivery. `GET /v1/sessions`
answers the project's live sessions and takes a `label`, so a poll on a schedule you choose asks
for each installation's label and treats an empty list as that host being down:

```
GET /v1/sessions?label=lobby-north
Authorization: Bearer <secret key with sessions:read>
```

`bitskiff sessions list` is the same read from the command line; each row carries its `label`.
Page both with `cursor` in and `nextCursor` out. The other way is to watch the host itself: an
unattended host has its own health surface, which
[Unattended hosts](/docs/guides/unattended-hosts/) covers, and a host that can reach your backend
can report to it directly.

The body wraps the fields above in one envelope, `orgId` and `projectId` beside the delivery's own
`id`, `type` and `at` on every kind:

```
{
  "id": "u2fq6nz3wk7mcx4tbr5dh2pyga",
  "type": "limit.warning",
  "at": 1758700000000,
  "orgId": "org_...",
  "projectId": "proj_...",
  "data": {
    "orgId": "org_...",
    "limit": "monthlySpendCap",
    "scope": "project",
    "scopeId": "proj_...",
    "value": 812,
    "ceiling": 1000,
    "requestId": "u2fq6nz3wk7mcx4tbr5dh2pyga"
  }
}
```

The `id` field is the delivery's own id -- key your deduplication on it, not on the body's contents.
The `requestId` field inside `data` is the request that raised the event; a notice like this one answers no
request, so it carries the event's own id instead -- the same value as `id` here -- which still
gives a retry the same value every time.

Every name under `data` is camelCase, on every event. On a notice (`limit.warning`,
`limit.exceeded` and `open.refused`), a field the event has no value for is left out rather than
sent empty: `keyId` is there only for a cap a key holds, and a paced `open.refused` carries no
`label`. A usage record (`session.ended` and `client.gone`) carries every field, with its zero
value (`0`, `""` or `{}`) where it has none.

Those five, session usage records included, are what a webhook delivers. Write your handler so an
event kind it does not know is ignored rather than an error. Nothing beyond them reaches your URL:
a session opened, a host away and a client joined are read from the REST routes or watched on the
host, not delivered here.

## Missed deliveries

A delivery is retried for 24 hours and then dropped, so a receiver down for longer than that
loses the record. `GET /v1/project/events` is where you read it back: the same events, in the
order they were queued, kept for seven days.

```
GET /v1/project/events?cursor=<cursor>&limit=100
Authorization: Bearer <secret key with usage:read>
```

Each event carries the `id`, `type` and `at` of the delivery and its `data` as a JSON string, the
same bytes your URL would have received. Page with `nextCursor`: send it back as `cursor` until a
response comes back without one, which is how the last page says it is the last.

Keep the last cursor you read. A receiver that comes back after an outage then catches up with one credential it
already has.

## Next steps

- [Session lifecycle](/docs/guides/lifecycle/)
- [Node hosts](/docs/guides/node-hosts/)
