Bitskiff

Webhooks

On this page

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):

Text
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:

Text
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:

RuleWhat is required
Schemehttps only
Credentialsnone in the URL
Fragmentnone
Hostresolves to a public IP address—no loopback, link-local, private or carrier-grade-NAT range
Redirectsnever followed; a 3xx response is a delivery failure, not a hop
Timeout5 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:

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);
}
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);
}

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:

KindFires whenCarries
limit.warningA metered quota crosses 80% of its ceiling for the current periodthe 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.exceededThe same quota reaches its ceilingthe same fields as limit.warning
open.refusedA host's open is turned awaythe 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.endedA session ends, in a project whose kinds lists itSessionUsage, the session's usage record
client.goneA client leaves, in a project whose kinds lists itClientUsage, 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:

Text
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 covers the monthly read, and a Stripe sample is on the downloads page.

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:

Text
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 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:

Text
{
  "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.

Text
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