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