# REST API

Everything an SDK does is an HTTP call you can make yourself, and every one of them is on this page.

Some names on this wire are not the words the rest of the site uses.

| On the wire | The word this site uses |
|---|---|
| `participants`, `participantId`, the `participant-state` values they carry, the `participant_minutes` meter, `ParticipantView`, `ParticipantStats`, `ParticipantMeters` | client |
| `seat`, `seatClass`, `seatDeadlineMs`, the `seat_spent` error code | a client's place |
| the `"admin"` value of a key's `type`, the `admin_key` audit actor, the `/admin-keys` routes | organization key |

## Base URL

Every path hangs off one address, and each environment has its own. This site was built for **dev**, whose API answers on `https://api.dev.bitskiff.com`. A key, a module or a CLI built for one environment talks only to that one. Every path is versioned under `/v1`, and a 64-bit number travels as a string.

A request that asks for JSON gets JSON: send `Accept: application/json` and both bodies are the JSON on this page. `Accept` decides. With no `Accept` the answer is encoded the way the request body was, and a request that carries neither is answered in protobuf.

## Bodies

These bodies are protobuf messages written out as JSON, and one rule of that writing shows through. A field at its zero value is left out of the JSON entirely. A `false`, a `0`, an empty string and an empty map never appear, so a field that is absent and a field at its zero value say the same thing.

A list is the exception: every repeated field is present, and an empty one is `[]`. So `GET /v1/sessions` on a project with nothing open answers `{"sessions": []}`, and reading a list off any answer on this page is safe without a guard.

A few fields carry presence of their own, and for those a zero and an absent field are two different answers. The limits on a key and the fields of a patch body work that way: a `0` is a ceiling of zero, and leaving the field out is what asks for no change.

So do `hostResumeWindow` and `warningLead` on a session's config: a `0s` asks for no host grace and no warning, and leaving the field out takes the project default.

The examples are abridged. Each one shows the fields worth reading for that call; a live answer carries the rest of the object as the tables here describe it.

## Credentials

Every request carries one credential, and its prefix says which class it is. The class decides which routes it reaches at all; the permissions on the key or token decide the rest. The last two rows are what a route asks for rather than credentials of their own.

| What a route asks for | What it is |
|---|---|
| publishable key | Starts `bsk_pk_`. Safe to ship in a page, because it works only from an origin you allowed. It opens sessions and does nothing else. |
| secret key | Starts `bsk_sk_`. For your own backend or a device you control. A request carrying an `Origin` header is refused, so it cannot be used from a browser. |
| token | Starts `bsk_at_`. What a secret or organization key mints for one user or device, for a while. Hand it to a page or an app that is already behind your own sign-in. |
| organization key | Starts `bsk_ak_`. The management credential: projects, keys, limits, settings and the audit log, across an organization. |
| host token | Starts `bsk_ht_`. Handed to the host when the session opens. It gets the host back into its own session after a reload or a restart. |
| resume token | Starts `bsk_rt_`. Held by a client so it can return to the session it already joined. |
| console sign-in | Starts `bsk_cs_`. What the console holds after you sign in. It opens no session, and an integration never holds one. |
| manage | Not a credential of its own: what may administer one running session. A host token, a resume token whose client holds `manage`, or a secret key, organization key or token carrying the `sessions:manage` permission. |
| no credential | 12 routes need none: the two a client calls with a join code, the two sign-in-link routes, and the two that say what this endpoint is. |

## Headers

Six headers, and a credential never travels in a path, a query string or a fragment.

| Header | What it carries |
|---|---|
| `Authorization` | `Bearer <credential>`. The one header every credential travels in. |
| `Accept` | `application/json` to get the JSON on this page. It is the header that decides. Leave it off and the answer is encoded the way the request body was, so a request sent with `Content-Type: application/json` is answered in JSON; leave both off and the answer is protobuf. |
| `Content-Type` | `application/json` on every request that carries a body, and the one header that makes the bodies on this page sendable as they are written. Any type but `application/json` or `application/x-protobuf` is refused `validation` with the message `unsupported request media type` and `details.header` `Content-Type`. Leaving it off leaves the body unread, so a route that needs one refuses `validation`. A `GET` or a `DELETE` carries no body and needs no `Content-Type`. |
| `X-Bitskiff-Project` | The project slug, such as `acme-demo`. Send it on every project-scoped route when the credential is an organization key or a console sign-in, either of which can serve more than one project. A `proj_` id is still accepted where one is already in use. |
| `Idempotency-Key` | Optional, on `POST /v1/sessions`. 16 to 128 printable ASCII characters of your own randomness; anything else is refused `validation` with `details.header` `Idempotency-Key`, and so is the header itself on a publishable key that carries no token. Sent again while the first open is still running it is refused `idempotency_in_flight`; sent again with the same body inside the replay window it answers that first session rather than opening a second one. The window is 24 hours when the call carries a key, and 60 seconds when it carries a token: a token-backed retry later than a minute opens a second session. |
| `X-Bitskiff-SDK` | Optional. Which SDK is calling and its version: `ts-host/<semver>` from a host, `ts-client/<semver>` from a client, such as `ts-client/0.1.0`. The SDKs send it themselves, and the routes with an `sdk` field carry the same value in the body. |

Which permission a credential needs is on the route itself, under **Permission**. [Credentials](/docs/guides/credentials/) says what each name covers and which class of credential may hold it.

```http
POST /v1/sessions
Authorization: Bearer bsk_ak_...
Accept: application/json
Content-Type: application/json
X-Bitskiff-Project: acme-demo
```

## Errors

Every response that is not a 2xx is the same object.

| Field | What it carries |
|---|---|
| `code` | The one field to branch on. Every value it takes is in the table at the end of this page. |
| `message` | A sentence for a person. Never parse it. |
| `retryable` | Whether sending this same request again can work. |
| `retryAfterMs` | How long to wait first. The `Retry-After` header carries the same wait in seconds. |
| `requestId` | The handle support traces a refusal by. Also the `X-Bitskiff-Request-Id` response header. |
| `closeCode` | Present only on a refusal that ended a session or a client: the code it was closed with. A read carries none, whatever it refuses. |
| `details` | What the refusal was about, as string keys. Every key it can carry is in the table under this one, and every value `reason` takes in the table after that. |

The zero-value rule under Bodies holds here too: `retryable: false`, `retryAfterMs: 0`, `closeCode: 0` and an empty `details` map never appear.

Every key `details` can carry. A refusal carries the ones its own rule has an answer for and no others, so read a key only when it is there.

| `details` key | Type | What it carries |
|---|---|---|
| `field` | string | The JSON path of the request field at fault, in the spelling the caller sent it, or the query parameter's name on a `GET`: `limit` and `cursor` on a paged route, `subject`, `hostState`, `kind`, `since`, `until`. |
| `option` | string | The SDK option at fault, in the spelling the developer writes it and dotted where it is nested (`reconnect.base`), on a refusal an SDK raises locally before any request goes out. |
| `header` | string | The request header at fault, in its canonical spelling: `Content-Type`, `Accept`, `Idempotency-Key`, `X-Bitskiff-Project`, `X-Bitskiff-Key`. |
| `path` | string | `<METHOD> <path>` as it was sent, on the `not_found` for a path this service does not carry and on the `405` for a documented path asked for with a method it does not answer. |
| `named` | string | What an out-of-reach read asked for, on the one `not_found` step 4. |
| `sessionId` | string | The session id the request named, as it was sent, on `session_not_found`. |
| `reason` | string | Which rule refused, from the vocabulary in the table below. |
| `class` | string | The credential classes this route DOES admit, in the words teaches (`organization key or console sign-in`) -- not the class that was presented, which the caller already knows: the refusal is there to say what would work. |
| `credential` | string | The prefix of the credential that was presented (`bsk_pk_`), never the credential itself. |
| `permission` | string | The permission the route asked for and the credential does not hold, on a `missing_permission` refusal. |
| `object` | string | The resource inside the caller's own scope that it may not touch, as `<type>:<id>` (`project:proj_...`): `, and only for something the caller can already reach. |
| `keyId` | string | The key a refusal is about, on the `own_line` and `not_descendant` refusals. |
| `role` | string | The organization role a key may not hand out, on a `role_above_key` refusal. |
| `policy` | string | The organization policy that refused the act, on a `policy` refusal: `orgKeyMinting` or `deviceSignIn`. |
| `expiredAt` | unixMs | When the organization's plan expired, Unix milliseconds, on `plan_expired` to the developer; a page never receives it. |
| `supported` | string | The protocol versions this service does speak, on `protocol_unsupported`. |
| `ceiling` | integer | A console-plane ceiling on a `validation` refusal, as well as the limit key of the table above. |

Every value `details.reason` takes. One vocabulary for every code that carries it, so a branch on the reason is a branch on the rule that refused rather than on a sentence.

| `details.reason` | On | What it says |
|---|---|---|
| `absent` | `invalid_key`, `origin_not_allowed` | Nothing was presented: no `Authorization: Bearer` at all, or on the join routes no client key in `X-Bitskiff-Key` and no token (`details.header` names `X-Bitskiff-Key`) outside the sample client page's keyless case; or a host key with no `Origin` header, which is a caller that is not a browser, or a client key with neither an `Origin` header nor an `origin` declaration. |
| `malformed` | `invalid_key` | A credential was presented and carries none of the prefixes. |
| `unknown` | `invalid_key`, `invalid_token`, `handoff_invalid` | Well-formed and this deployment holds no such record: a key of another deployment, a truncated one, or a single-use sign-in credential that was never minted, is already spent or has been superseded -- a device code included, since redeeming one deletes it. |
| `revoked` | `invalid_key`, `invalid_token` | This deployment issued it and it has been revoked; mint a fresh one. |
| `spent` | `handoff_invalid` | The handoff ticket was already redeemed once, or a final refusal naming it spent it; the app shows one line and its scanner. |
| `superseded` | `handoff_invalid` | The page minted a later handoff ticket for the same place, and one is pending at a time; the later ticket is the live one. |
| `cancelled` | `handoff_invalid` | The page cancelled the handoff before this redeem (`DELETE .../clients/{clientId}/handoff`); the place stays the page's. |
| `lapsed` | `handoff_invalid` | On a resume by the app: the handoff it redeemed never committed, because the window ended before its leg attached, so the place stayed the page's and the app's stored record is deleted. |
| `expired` | `invalid_key`, `invalid_token`, `handoff_invalid` | Past its `expiresAt`; mint a fresh one. |
| `mismatch` | `origin_not_allowed` | The `Origin` the browser sent is not on this publishable key's allowlist, or, on a client key and only when the request carries no `Origin` header or `Origin: null`, the body's `origin` declaration is not. |
| `other_project` | `wrong_client` | The session belongs to another project than the client key's; raised at `inspect` and `join` before any join grant or ticket, and naming nothing else. |
| `other_host` | `wrong_client` | The session's opener key is not one of the host keys the client key serves; raised at `inspect` and `join` before any join grant or ticket, and naming nothing else. |
| `not_for_class` | `forbidden` | The credential is well-formed and its class may not call this route; `details.class` names the classes that may. |
| `missing_permission` | `forbidden` | The class admits the route and the effective permission list does not carry the word; `details.permission` names it. |
| `own_line` | `forbidden` | A key acted on itself or an ancestor, which is what stops a leaked key rotating the key its owner would stop it with. |
| `not_descendant` | `forbidden` | A key rotated or revoked an organization key that does not descend from it: a console-made key, a sibling's, the login key on another machine; `details.keyId` names the target. |
| `policy` | `forbidden` | An organization policy refuses the act: `orgKeyMinting: console` on a key making or rotating an organization key, `deviceSignIn: refused` on a console sign-in a device approval issued (`bitskiff login`, `init`), the login key's mint among its requests, and on every request of a login key, whenever it was minted; `details.policy` names the policy. |
| `project_archived` | `forbidden` | The project is archived, so it opens no session, creates or rotates no key and mints no token; `details.object` names it, and an owner can unarchive it within 30 days of archiving. |
| `unarchive_window_closed` | `forbidden` | The project was archived more than 30 days ago, so it can no longer be unarchived; `details.object` names it. |
| `role_above_key` | `forbidden` | An organization key invited a role above its own, or changed or removed a member who holds one; `details.role` names it. |
| `own_role` | `forbidden` | A console user tried to change their own organization role; another admin or owner changes it. |
| `other_app` | `wrong_client` | On `POST /v1/handoff`: the client key is this project's but not the registered app's for it; only the registered app's client key redeems a handoff ticket. |
| `place_not_attached` | `forbidden` | On `POST .../clients/{clientId}/handoff`: a handoff ticket is minted only for a place whose record at the service reads attached, present and not released, whatever transport the page is on. |
| `e2ee_session` | `forbidden` | On `POST .../clients/{clientId}/handoff`: no handoff in an end-to-end encrypted session joined without a token, because the app's new key would need a re-confirmation for a place that already exists, which is not designed yet; under the authenticated join the token's `cnf` vouches for the app's key and the handoff is allowed. |
| `token_required` | `forbidden` | The `require_token` row, the project's word clamped by the opening key's, refused the join: a join with no token where it is `token` or `token_bound_to_session`, or a token that names no session where it is `token_bound_to_session`. |
| `token_not_for_session` | `forbidden` | A join token bound to one session was presented at a join into another session of its project, whatever the `require_token` row says. |
| `grant_expired` | `invalid_code` | The join grant presented on `POST /v1/join` is past its `joinGrantTtl` or was never minted; re-entry is another scan. |
| `grant_used` | `invalid_code` | The join grant was already redeemed, and this is not a repeat of that redemption inside its 30 s replay window; re-entry is another scan. |
| `grant_holder_mismatch` | `invalid_code` | The `holder` presented with the join grant does not hash to the `holderHash` its reservation carried, so the grant was copied and is not this page's. |
| `nothing_open` | `invalid_code` | A fixed code that exists in the client key's project and that no open session holds the claim of: the code is good and nothing is running here, which the reference join page shows as that and not as an error. |
| `not_transferable` | `forbidden` | The invitation was sent to another address. |
| `device_code_denied` | `forbidden` | The person on the approval page pressed Cancel, so this device code will never sign anybody in; a terminal stops polling at once. |
| `email_unverified` | `forbidden` | On `POST /v1/auth/sign-in/oidc/{provider}/callback`: the provider does not vouch for the email (Google's `email_verified` or Microsoft's `xms_edov` is not `true`), so it never links and never creates a console user; sign in by an email link to that address instead. |
| `sign_up_closed` | `forbidden` | On `POST /v1/auth/sign-in/oidc/{provider}/callback`: a new address under `identity.selfSignUp: allowlist` that neither the sign-up allowlist nor an invitation admits; the caller has just proved they hold the address, so the refusal names its reason. |
| `federated_link_required` | `forbidden` | On `POST /v1/auth/sign-in/oidc/{provider}/callback`: a provider identity new to Bitskiff whose verified email is a console user on another Identity Platform account. |
| `federated_identity_in_use` | `forbidden` | On `POST /v1/auth/sign-in/oidc/{provider}/callback`: the provider's subject is linked to one console user, and the popup landed on another console user's Identity Platform account or on one its own user does not hold. |
| `mfa_required` | `forbidden` | The console user has no second factor and must enrol one: an organization owner or a member under `mfaRequired` past `identity.mfaEnrolmentGraceS`, or anyone on a step-up route. |
| `mfa_step_up` | `forbidden` | The console user has a second factor, but did not pass it within `identity.stepUpWindowS`; pass it again and retry once. |
| `staff_access_refused` | `forbidden` | A `platform:mrc#staff` read of an organization whose owner set `staffAccess` to `refused`; the staff lists count such an organization without naming it. |
| `not_a_url`, `scheme`, `credentials`, `fragment`, `unresolvable`, `not_public` | `forbidden` | Which rule a webhook URL broke: the six are the whole of the policy. |
| `duration` | `validation` | A duration string is not one: it broke the grammar -- a bare number, a space, a sign, an unknown or out-of-order unit, or a count whose parse is not whole milliseconds. |
| `not_our_api` | `unavailable` | Raised by the SDK, never by the service: the answer came from something other than the service -- a captive portal's page, a 302 or 511 from a network box, a 2xx without the service's content type -- so it is a transport failure the SDK retries, and it never ends a session or deletes a stored credential. |
| `last_owner` | `validation` | Removing or demoting this member would leave the organization with no owner; make another member an owner first. |
| `last_default` | `validation` | The billing account is the organization's default and an organization keeps exactly one; make another billing account the default instead. |
| `invoice_status` | `validation` | The invoice's status does not allow that change: only an issued invoice is marked paid, and only an issued or paid one is voided; the message names the status it has. |
| `month_not_ended` | `validation` | An invoice is issued for a calendar month (UTC) that has ended; name an earlier month. |
| `max_members_per_org` | `validation` | The organization is at its member ceiling; `details.ceiling` carries the number. |
| an end reason | `session_ended`, `seat_spent` | Why the session or the seat is gone, from the `reasons` registry's session surface (`host_closed`, `idle_timeout`, `max_session_time`, `reconnect_budget`, `background_budget`): present when the record still says, absent when it does not. |

A path no route on this page carries is refused `not_found` with a 404, the message `no such route` and `details.path` naming the method and path that was sent. A path outside `/v1` answers the same object, so the base URL mistyped reads:

```json
{
  "code": "not_found",
  "message": "no such route",
  "requestId": "3f1b6a52-9c4d-4f0a-8b21-6de0c2f4a917",
  "details": { "path": "GET /sessions" }
}
```

An id, slug or token the caller cannot reach is the same `not_found`, with the message `nothing this caller can reach is named that` and `details.named` carrying what was asked for. It is the same answer whether the thing exists or not: that is what keeps one caller from learning another's ids. A session id is read the same way as any other id, and no read carries a `closeCode`.

A body the route cannot use is refused `validation`. `details.field` is the JSON path of the field at fault, in the spelling you sent it, and the message says what is wrong with it:

```json
{
  "code": "validation",
  "message": "the body field config.maxClients takes a number",
  "requestId": "3f1b6a52-9c4d-4f0a-8b21-6de0c2f4a917",
  "details": { "field": "config.maxClients" }
}
```

A field the route does not take is the same refusal, with the message `the body carries a field this route does not take` and the path after it. A body that is not JSON at all has no field to point at, so that one is `body is not JSON` with no `details`.

A credential that is present and is none of the prefixes under Credentials is refused `invalid_key` with the message `this credential is not a key or token this service issued`.

A request that sends no `Authorization` header, or sends one that is not `Bearer`, is the `invalid_key` that says `this route needs a credential in Authorization: Bearer`. The two are different sentences, so a truncated key does not read as a missing one.

A limit that was reached is refused `rate_limited` or `quota_exceeded`. `details.limit` names the limit, and the message names the same limit in the same spelling, which is the one this site uses everywhere:

```json
{
  "code": "rate_limited",
  "message": "too many requests for sessionOpensPerMinute; wait and retry",
  "requestId": "3f1b6a52-9c4d-4f0a-8b21-6de0c2f4a917",
  "retryable": true,
  "retryAfterMs": 10000,
  "details": { "limit": "sessionOpensPerMinute", "scope": "project" }
}
```

Sending one `Idempotency-Key` twice with a different body is refused `validation` with the message `this Idempotency-Key was used with a different request body` and `details.header` naming the header rather than a field. A retry has to carry the body the first attempt did; a new request needs a new key.

Retry on `retryable` alone, after `retryAfterMs`. The `clears` column in the table at the end of this page is a separate fact, for a loop that keeps trying: a concurrency or rate refusal clears on its own, so the same call can simply be made again. A cap clears at the period roll or at a raise.

## Paging

Seven routes answer a page at a time, and the contract is one contract. Send `cursor` with a `limit`; read `nextCursor` off the answer and send it back as the next `cursor`. The last page answers no `nextCursor`, so read until it stops coming rather than until a page is empty.

## Sessions

Open a session, read it while it runs, and end it.

### `POST /v1/sessions`

Open a session. Answers the session, the host token, the host's own ticket and the join code a client joins with.

Who may call it: A publishable key, or a secret key, organization key or token carrying `sessions:open`.

Permission: `sessions:open`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `config` | [SessionConfig](#sessionconfig) | no | Partial; a field you leave out takes the project default. Every duration is a duration string -- `15s`, `2m`, `1h30m` -- and comes back as you wrote it, or in canonical form when a project ceiling moved it. `hostResumeWindow` and `warningLead` read `0s` as a request rather than as silence: no host grace at all, no warning at all. |
| `label` | string | no | Your own name for this session, 64 characters at most; a longer one is refused `validation` with `details.field` `label`. It comes back on every read of the session and is what the console shows. |
| `metadata` | object, string keys to string | no | Your own keys and values, carried with the session and returned on every read. The service stores them and never reads them. At most 16 entries, refused `validation` with `details.field` `metadata`, and each value at most 256 bytes, refused with `details.field` naming `metadata.<key>`. |
| `protocol` | [ProtocolRange](#protocolrange) | no | The protocol versions the calling SDK can speak. The service pins one for the life of the session and refuses `protocol_unsupported` when no version is in both ranges. |
| `sdk` | string | no | Same value as X-Bitskiff-SDK. |
| `channels` | array of [ChannelRow](#channelrow) | no | The host's presented channel table: every row the host declared before open, ids assigned. |
| `fixedCodeId` | string | no | The id of the fixed code this session claims, the host SDK's `fixedCode` open option; empty claims nothing. |
| `origin` | string | no | Where a native build says it is: the custom scheme its host key lists, `com.studio.racer://host`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `session` | [SessionView](#sessionview) | The session that was opened: its config as it was clamped, the ceilings it was clamped against, and its state. |
| `hostToken` | string | The host's own credential for this session, returned here once. A host presents it to read and close the session, and to take a new host ticket after a reload. |
| `hostTicket` | [Ticket](#ticket) | The host's own ticket, carrying the `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST` and `GRANT_MANAGE` grants. |
| `joinCode` | [JoinCodeMaterial](#joincodematerial) | The material a join code is computed from -- `sessionCode`, `secret`, `regionHint` and the `stepS` it rotates on -- and the `joinUrl` it rides on. The code itself is not in here: an SDK computes it locally, and over HTTP you compute it yourself, as [The join code](#the-join-code) sets out. When the code next rotates is `deadlines.codeRotatesAtMs` on the session. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered, so every instant in this message is read against the clock that set it. |
| `limits` | [SessionLimits](#sessionlimits) | The caps this session's clients are held to. |
| `capacity` | [CapacityNotice](#capacitynotice) | The project's standing against the caps this session's own usage spends, at the moment it opened. It is sent only when at least one of those caps has reached its warning point, 80% of its ceiling, or is spent; when they are all clear the field is left out, and an absent notice means exactly `{ status: "ok", limits: [] }`. Guard the field before reading `capacity.limits`. The caps with a row here are `clientMinutesPerDay` (at the project and at the key that opened the session), `monthlySpendCap`, `turnBytesPerMonth` and `samplePageSessions`. `concurrentSessions` never has one: it refuses the next open rather than this session, and the refusal is where you read it. |
| `channels` | array of [ChannelRow](#channelrow) | The effective channel table: the presented rows, defaults filled and clamped. |
| `stunUrls` | array of string | Bitskiff's STUN URLs: the relay's `stun:` and `stuns:` entries and no credential, the servers a `peer` offer carries. |

#### Example request

```json
{
  "config": {
    "maxClients": 2
  },
  "label": "lobby 14"
}
```

#### Example response

```json
{
  "session": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "projectId": "proj_g1qr384an9asb4kdp10e",
    "state": "open",
    "label": "lobby 14",
    "region": "us-central1",
    "openedAt": "1774000000000"
  },
  "hostToken": "bsk_ht_...",
  "hostTicket": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "participantId": "h_0mFkpdUNnY5zgH5t3k7Qgv",
    "grants": [
      "GRANT_RECEIVE_INPUT",
      "GRANT_BROADCAST",
      "GRANT_MANAGE"
    ],
    "opener": true,
    "expiresAt": "1774000060000"
  },
  "joinCode": {
    "sessionCode": "AbCdEfG",
    "joinUrl": "https://example.com/join#",
    "stepS": 30
  },
  "serverTimeMs": "1774000000000"
}
```

### `POST /v1/sessions/{sessionId}/host-ticket`

Give the host its place back after a reload or a restart, and rotate its host token.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `protocol` | [ProtocolRange](#protocolrange) | no | The protocol versions the calling SDK can speak. The service pins one for the life of the session and refuses `protocol_unsupported` when no version is in both ranges. |
| `sdk` | string | no | Which SDK is calling and its version, such as `ts-host/0.1.0`. It is carried on the session's own rows. |
| `channels` | array of [ChannelRow](#channelrow) | no | A resume presents the same table the open did: the service clamps it under the session's key and compares the result to the session's effective table, field by field, ids included. |
| `evictedEpoch` | number | no | Set only after the engine closed the host's connection because another connection used its identity: the host epoch that connection was minted under. The service answers `replaced` when a later host ticket has moved the epoch since, and otherwise resumes the host with `replay` set. |
| `lostNode` | [LostNode](#lostnode) | no | Set only by a host whose link the custom engine ended with `bye` `node_lost` or `server_shutdown`: the process that `bye` named. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `session` | [SessionView](#sessionview) | The session this ticket is for: its config as it was clamped, the ceilings it was clamped against, and its state. |
| `hostTicket` | [Ticket](#ticket) | What the host connects its transport with. |
| `hostToken` | string | Rotated; the predecessor stays good until the successor is first presented, and no clock ends it before that. |
| `joinCode` | [JoinCodeMaterial](#joincodematerial) | The material a join code is computed from -- `sessionCode`, `secret`, `regionHint` and the `stepS` it rotates on -- and the `joinUrl` it rides on. The code itself is not in here: an SDK computes it locally, and over HTTP you compute it yourself, as [The join code](#the-join-code) sets out. When the code next rotates is `deadlines.codeRotatesAtMs` on the session. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered, so every instant in this message is read against the clock that set it. |
| `limits` | [SessionLimits](#sessionlimits) | Re-sent on every host ticket, so a host that resumes re-reads it. |
| `channels` | array of [ChannelRow](#channelrow) | The session's effective channel table, answered when the presented table, clamped under the session's key, matched it field by field. |
| `replay` | boolean | True when the request named an evicted epoch and it had not moved: the eviction was a replayed ticket, and this is the ordinary resume. |
| `relocation` | [Relocation](#relocation) | Present when the request carried `lostNode` and the session was re-placed, by this request or an earlier one; absent on every other answer. |
| `stunUrls` | array of string | Bitskiff's STUN URLs, as `OpenSessionResponse.`stunUrls`: re-sent on every host ticket, so a host that resumes re-reads them. |

#### Example request

```json
{
  "protocol": {
    "min": 1,
    "max": 1
  },
  "sdk": "ts-host/0.2.0"
}
```

#### Example response

```json
{
  "session": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "state": "open",
    "label": "lobby 14"
  },
  "hostTicket": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "participantId": "h_0mFkpdUNnY5zgH5t3k7Qgv",
    "opener": true,
    "expiresAt": "1774000360000"
  },
  "hostToken": "bsk_ht_...",
  "serverTimeMs": "1774000300000"
}
```

### `POST /v1/host-ticket`

The same, for a host that holds the host token and no session id: the token says which session it is.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `protocol` | [ProtocolRange](#protocolrange) | no | The protocol versions the calling SDK can speak. The service pins one for the life of the session and refuses `protocol_unsupported` when no version is in both ranges. |
| `sdk` | string | no | Which SDK is calling and its version, such as `ts-host/0.1.0`. It is carried on the session's own rows. |
| `channels` | array of [ChannelRow](#channelrow) | no | A resume presents the same table the open did: the service clamps it under the session's key and compares the result to the session's effective table, field by field, ids included. |
| `evictedEpoch` | number | no | Set only after the engine closed the host's connection because another connection used its identity: the host epoch that connection was minted under. The service answers `replaced` when a later host ticket has moved the epoch since, and otherwise resumes the host with `replay` set. |
| `lostNode` | [LostNode](#lostnode) | no | Set only by a host whose link the custom engine ended with `bye` `node_lost` or `server_shutdown`: the process that `bye` named. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `session` | [SessionView](#sessionview) | The session this ticket is for: its config as it was clamped, the ceilings it was clamped against, and its state. |
| `hostTicket` | [Ticket](#ticket) | What the host connects its transport with. |
| `hostToken` | string | Rotated; the predecessor stays good until the successor is first presented, and no clock ends it before that. |
| `joinCode` | [JoinCodeMaterial](#joincodematerial) | The material a join code is computed from -- `sessionCode`, `secret`, `regionHint` and the `stepS` it rotates on -- and the `joinUrl` it rides on. The code itself is not in here: an SDK computes it locally, and over HTTP you compute it yourself, as [The join code](#the-join-code) sets out. When the code next rotates is `deadlines.codeRotatesAtMs` on the session. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered, so every instant in this message is read against the clock that set it. |
| `limits` | [SessionLimits](#sessionlimits) | Re-sent on every host ticket, so a host that resumes re-reads it. |
| `channels` | array of [ChannelRow](#channelrow) | The session's effective channel table, answered when the presented table, clamped under the session's key, matched it field by field. |
| `replay` | boolean | True when the request named an evicted epoch and it had not moved: the eviction was a replayed ticket, and this is the ordinary resume. |
| `relocation` | [Relocation](#relocation) | Present when the request carried `lostNode` and the session was re-placed, by this request or an earlier one; absent on every other answer. |
| `stunUrls` | array of string | Bitskiff's STUN URLs, as `OpenSessionResponse.`stunUrls`: re-sent on every host ticket, so a host that resumes re-reads them. |

#### Example request

```json
{
  "protocol": {
    "min": 1,
    "max": 1
  },
  "sdk": "ts-host/0.2.0"
}
```

#### Example response

```json
{
  "session": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "state": "open",
    "label": "lobby 14"
  },
  "hostTicket": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "participantId": "h_0mFkpdUNnY5zgH5t3k7Qgv",
    "opener": true,
    "expiresAt": "1774000360000"
  },
  "hostToken": "bsk_ht_...",
  "serverTimeMs": "1774000300000"
}
```

### `POST /v1/sessions/{sessionId}/resume`

Come back to the place a client already holds, for as long as its reconnect budget lasts.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `protocol` | [ProtocolRange](#protocolrange) | no | The protocol versions the calling SDK can speak. A resume is refused `protocol_unsupported` when the session's pinned version is outside this range. |
| `sdk` | string | no | Which SDK is calling and its version, such as `ts-client/0.1.0`. |
| `evictedJti` | string | no | Set only after the engine closed this client's connection because another connection used its identity: the `jti` of the ticket that connection used. The service answers `replaced` when a later ticket was minted for this client since, and otherwise resumes it with `replay` set. |
| `lostNode` | [LostNode](#lostnode) | no | Set only by a client whose link the custom engine ended with `bye` `node_lost` or `server_shutdown`: the process that `bye` named. |
| `environment` | [ClientEnvironment](#clientenvironment) | no | As JoinRequest.environment. |
| `metadata` | object, string keys to string | no | As JoinRequest.metadata, read only beside a stated environment: a non-empty map replaces the place's, and an empty one keeps it, so a reload that restates nothing clears nothing. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `ticket` | [Ticket](#ticket) | What this client connects its transport with: a fresh one, for the connection it is making now. |
| `resumeToken` | string | Token rotated. |
| `seatDeadlineMs` | string (64-bit integer) | Unix ms on the service's clock past which this resume token is refused and the client's place is gone. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered. |
| `limits` | [SessionLimits](#sessionlimits) | Re-sent on every resume, so a cap change reaches a live client. |
| `policyDeadlineMs` | string (64-bit integer) | As JoinResponse.`policyDeadlineMs`, read again at the resume. |
| `replay` | boolean | True when the resume named an evicted ticket and nothing was minted since: the eviction was a replayed ticket, and this is the ordinary resume. |
| `handoffFailed` | string | On an evicted page's resume only: the stage this resume failed the place's redeemed handoff at, `crashed` when the engine read found the successor's connection gone. One of `HANDOFF_STAGE_MINTED`, `HANDOFF_STAGE_REDEEMED`, `HANDOFF_STAGE_CRASHED`, `HANDOFF_STAGE_REFUSED`, `HANDOFF_STAGE_CANCELLED`. |
| `registeredApps` | array of [RegisteredApp](#registeredapp) | The native apps the project registered, as JoinResponse.`registeredApps` and from the same project read: a page that resumes a stored place on a fresh load, the page a client returns to through `/return` among them, learns them here and nowhere else. |

#### Example request

```json
{
  "protocol": {
    "min": 1,
    "max": 1
  },
  "sdk": "ts-client/0.2.0"
}
```

#### Example response

```json
{
  "ticket": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
    "expiresAt": "1774000360000"
  },
  "resumeToken": "bsk_rt_...",
  "seatDeadlineMs": "1774000960000",
  "serverTimeMs": "1774000300000"
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/handoff`

A client on a page asks for a handoff ticket for its own place, so the project's registered app can take that place with no scan.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `hint` | [HandoffHint](#handoffhint) | no | The page's transport as it stands at the mint; absent when it states none. |
| `dataText` | string | no | The handoff data as a string. |
| `dataBytes` | string (base64) | no | The handoff data as bytes. |
| `hostIdentityPublic` | string (base64) | no | In an end-to-end encrypted session, the raw 32-byte Ed25519 host identity key the page's own `Welcome` verified against its fingerprint: required there, `validation` when absent or another length, and empty in a plain session. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `ticket` | string | The handoff ticket, `bsk_hf_` and 43 characters: carried in the handoff link's fragment only. |
| `expiresAtMs` | string (64-bit integer) | Unix ms on the service's clock past which the ticket is refused `handoff_invalid`, `expired`. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered; the client reads `expiresAtMs` against it. |

#### Example request

```json
{
  "hint": {
    "transport": "peer",
    "grade": "good",
    "rttMs": 12
  },
  "dataText": "player-7"
}
```

#### Example response

```json
{
  "ticket": "bsk_hf_...",
  "expiresAtMs": "1774000360000",
  "serverTimeMs": "1774000300000"
}
```

### `DELETE /v1/sessions/{sessionId}/clients/{clientId}/handoff`

A client on a page withdraws the handoff it started, so its place stays where it is.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

None. The service answers `204`.

### `POST /v1/sessions/{sessionId}/clients/{clientId}/presence`

A client says whether it is in the foreground or the background, and hears how much background time it has left.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `presence` | string | yes | What the client is now. A client is the only writer of its own presence, and its background budget runs from what it says here. One of `PRESENCE_PRESENT`, `PRESENCE_AWAY`. |
| `jti` | string | no | The `jti` of the newest ticket the client holds, from its join or its last resume. An `away` that names an older ticket comes from a page that has since reloaded and resumed, and changes nothing. Leave it empty to have the post read as the latest. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `presence` | string | The presence the service recorded. One of `PRESENCE_PRESENT`, `PRESENCE_AWAY`. |
| `backgroundMsLeft` | number | How much of the background budget is left, in milliseconds. Zero once it is spent. |
| `hostDeadlineMs` | string (64-bit integer) | The session's host grace as the service holds it when it answered: absolute Unix ms on the service's clock while the host is away, 0 while it is attached. |

#### Example request

```json
{
  "presence": "PRESENCE_AWAY"
}
```

#### Example response

```json
{
  "presence": "PRESENCE_AWAY",
  "backgroundMsLeft": 28000
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/leave`

A client says it is gone, so its place is freed without waiting for the connection to time out. Safe to send twice.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | no | Why the client is going: `host_unverified`, or the empty string for an ordinary leave. Any other value is refused `validation` with `details.field` `reason`. It reaches the host as the reason on `clientLeft`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `presence` | string | The presence the service recorded. One of `PRESENCE_PRESENT`, `PRESENCE_AWAY`. |
| `backgroundMsLeft` | number | How much of the background budget is left, in milliseconds. Zero once it is spent. |
| `hostDeadlineMs` | string (64-bit integer) | The session's host grace as the service holds it when it answered: absolute Unix ms on the service's clock while the host is away, 0 while it is attached. |

#### Example request

```json
{
  "reason": "host_unverified"
}
```

#### Example response

```json
{
  "presence": "PRESENCE_PRESENT"
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/unheard`

The host reports a client it holds as present that has sent nothing for 3 seconds. The host SDK sends this itself.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `engineSid` | string | yes | The engine sid the host last heard this client on. |
| `lastHeardMs` | string (64-bit integer) | no | Unix ms on the host's own clock; the service logs it and decides nothing on it. |
| `query` | boolean | no | A question, not a report: the service commits nothing and answers where the record stands and the budget it is running. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `detached` | boolean | This report committed the detach. |
| `connection` | string | The record's connection after the report: "attached", "detached", "reserved" or "gone"; empty when there is no record. |
| `budgetDeadlineMs` | string (64-bit integer) | The running `reconnect` or `background` budget of a `detached` record as the service holds it, and for a `query` of an `attached` one too: absolute Unix ms on the service's clock, 0 when none is running. |

#### Example request

```json
{
  "engineSid": "PA_7c2f1e9d",
  "lastHeardMs": "1774000003250"
}
```

#### Example response

```json
{}
```

### `GET /v1/sessions/{sessionId}/clients/{clientId}/budget`

The host asks where a client stands and which budget Bitskiff is running for it. The host SDK sends this itself.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `connection` | string | The record's connection: "attached", "detached", "reserved" or "gone"; empty when there is no record. |
| `budgetDeadlineMs` | string (64-bit integer) | The running `reconnect` or `background` budget of an `attached` or `detached` record as the service holds it: absolute Unix ms on the service's clock, 0 when none is running. |

#### Example response

```json
{
  "connection": "attached",
  "budgetDeadlineMs": "1774000125000"
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/release`

The host releases a client's place: the client stops using its Bitskiff connection and the host serves it over another transport. The host SDK sends this itself.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `transport` | string | yes | The name of the transport that carries the client for the span. |
| `ifLost` | string | no | The client's choice for a lost link, from its Release. One of `RELEASE_IF_LOST_REJOIN`, `RELEASE_IF_LOST_END`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `connection` | string | The record's connection after the call: "released" when the mark committed or was already there, "gone" for a place that is over. |
| `releasedAtMs` | string (64-bit integer) | Absolute Unix ms on the service's clock the place was marked released. |

#### Example request

```json
{
  "transport": "wifi_direct",
  "ifLost": "RELEASE_IF_LOST_REJOIN"
}
```

#### Example response

```json
{
  "connection": "released",
  "releasedAtMs": "1774000003250"
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/release-report`

The host reports what became of a released client: its link was lost, or it ended. The host SDK sends this itself.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `outcome` | string | yes | "lost": the link died and the client rejoins, so the service detaches the place and arms its reconnect budget. |
| `reason` | string | no | For "ended" only: "left" (the client's choice for a lost link, or its own leave) or "`backgroundBudget`" (its background budget, which the host runs for the span, ran out). |
| `releasedAtMs` | string (64-bit integer) | no | The release this report is about, as ReleaseResponse answered it. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `applied` | boolean | This report moved the place. |
| `connection` | string | The record's connection after the report: "released", "detached", "attached" or "gone". |
| `budgetDeadlineMs` | string (64-bit integer) | The `reconnect` budget a "lost" report armed: absolute Unix ms on the service's clock, 0 when none. |

#### Example request

```json
{
  "outcome": "lost",
  "releasedAtMs": "1774000003250"
}
```

#### Example response

```json
{
  "applied": true,
  "connection": "detached",
  "budgetDeadlineMs": "1774000045000"
}
```

### `POST /v1/sessions/{sessionId}/host-release`

The host lets its own Bitskiff connection go while every client it holds is released. The host SDK sends this itself.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `released` | boolean | The session is host_released after this call. |
| `releasedAtMs` | string (64-bit integer) | Absolute Unix ms on the service's clock the session went host_released. |
| `deadlines` | [SessionDeadlines](#sessiondeadlines) | The deadlines a released host holds on its own clock: max_time and the released ceiling. |
| `blockingClients` | array of string | When not released: the held clients that are not. |

#### Example response

```json
{
  "released": true,
  "releasedAtMs": "1774000004000",
  "deadlines": {
    "maxTimeDeadlineMs": "1774014400000",
    "releasedCeilingDeadlineMs": "1774086404000"
  }
}
```

### `GET /v1/sessions/{sessionId}/clients/{clientId}/place`

A client reads where its own place stands, so one that missed the host's release answer knows what to do. The client SDK sends this itself.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `connection` | string | The record's connection: "reserved", "attached", "detached", "released" or "gone". |
| `releasedAtMs` | string (64-bit integer) | When the place was marked released: absolute Unix ms on the service's clock; 0 unless released. |
| `releasedTransport` | string | The transport the host named at the release; empty unless released. |

#### Example response

```json
{
  "connection": "released",
  "releasedAtMs": "1774000003250",
  "releasedTransport": "wifi_direct"
}
```

### `POST /v1/sessions/{sessionId}/relay-credentials`

The host asks for Bitskiff's relay servers and one credential for one direct-path offer. The host SDK sends this itself.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `clientId` | string | no | The client the offer is for; empty is the host's own side. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `urls` | array of string | The relay's `turn:` URLs, udp and tcp. |
| `username` | string | `{expiry_unix}:{`participantId`}`, naming no project and no key. |
| `credential` | string | Base64 HMAC-SHA1 of `username` under the relay's shared secret. |
| `ttlSeconds` | number | The session's idle limit or 12 hours, whichever is shorter. |

#### Example request

```json
{
  "clientId": "p_MWnCKAv7YT4UCIX8ncPLUA"
}
```

#### Example response

```json
{
  "urls": [
    "stun:relay.example.com:3479",
    "turn:relay.example.com:3479?transport=udp",
    "turn:relay.example.com:3479?transport=tcp"
  ],
  "username": "1774043500:p_MWnCKAv7YT4UCIX8ncPLUA",
  "credential": "...",
  "ttlSeconds": 43200
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/closing`

A client whose page is closing says which connection is going. The client SDK sends this itself on `pagehide`.

Who may call it: The client's own resume token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `closingSid` | string | yes | The engine sid of the connection the client is closing. |

#### Response body

None. The service answers `204`.

#### Example request

```json
{
  "closingSid": "PA_7c2f1e9d"
}
```

#### Example response

```json
{}
```

### `POST /v1/sessions/{sessionId}/stats`

The host posts a minute of traffic, path and round-trip counters, its own and every client's.

Who may call it: A host token.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `windowStart` | string (64-bit integer) | yes | Unix ms; start of the interval this report covers. |
| `windowEnd` | string (64-bit integer) | yes | Unix ms. |
| `seq` | number | yes | Per host process, keyed with the session epoch: increments once per report and starts at 1 again when a restarted host process resumes the session under a new epoch. |
| `host` | [Stats](#stats) | no | The host's own counters. |
| `participants` | array of [ParticipantStats](#participantstats) | no | One entry per client the host counted traffic for over this window. |
| `control` | array of [ControlCount](#controlcount) | no | The control frames clients sent the host over this window, one entry per kind. A kind outside the control vocabulary is dropped, and a report naming more kinds than it has is refused `validation` with `details.field` `control`. The host's log, never a meter and never billed. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `capacity` | [CapacityNotice](#capacitynotice) | The project's standing again, read back after this report moved the counters, on the same rule as the open response: sent from the first cap at its warning point, left out while every cap is clear. This is how a live session learns inside 60 s that a cap is close, and a reader watching for a change treats an absent notice as `{ status: "ok", limits: [] }`. |

#### Example request

```json
{
  "windowStart": "1774000000000",
  "windowEnd": "1774000060000",
  "seq": 1,
  "host": {
    "windowMs": 60000,
    "path": "unknown",
    "traffic": [
      {
        "topic": "in",
        "direction": "to_host",
        "path": "unknown",
        "messagesCount": "120",
        "bytes": "24000",
        "droppedCount": "0"
      }
    ],
    "rtt": {
      "windowCount": 60,
      "windowP50Ms": 18,
      "windowP95Ms": 42,
      "windowMaxMs": 61,
      "totalCount": "600",
      "totalSumMs": "11400"
    }
  },
  "participants": [
    {
      "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
      "stats": {
        "windowMs": 60000,
        "path": "unknown"
      }
    }
  ]
}
```

#### Example response

```json
{}
```

### `POST /v1/sessions/{sessionId}/close`

End the session. Every client is disconnected, and the session comes back ended.

Who may call it: A host token, a resume token whose client holds `manage`, a secret key, organization key or token carrying `sessions:manage`, or a console sign-in.

Permission: `sessions:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | no | Max 128 chars; becomes ended.reason detail. |

#### Response body

The [SessionView](#sessionview) object, written out under **Objects**.

#### Example request

```json
{
  "reason": "the shift ended"
}
```

#### Example response

```json
{
  "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "state": "ended",
  "label": "lobby 14",
  "openedAt": "1774000000000",
  "endedAt": "1774000900000",
  "endedReason": "api_closed"
}
```

### `GET /v1/sessions`

List the project's live sessions, paged.

Who may call it: A secret key, organization key or token carrying `sessions:read`, or a console sign-in.

Permission: `sessions:read`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string, not in a body.

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string | no | Keep only the sessions this subject opened. Omit it for every subject. A token reads its own subject and nothing else, whatever it sends here. |
| `label` | string | no | Keep only the sessions whose label is exactly this. It is how you read one host: the label a host opens with is its own name across every session its loop opens. Omit it for every label. |
| `cursor` | string | no | Where to carry on from: the `nextCursor` the page before this one answered with. It is opaque, and it belongs to this listing and this project: one this service did not answer with, or one from another listing, is refused `validation` with `details.field` `cursor` rather than answering an empty page. |
| `limit` | number | no | How many sessions this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit` rather than shrunk. |
| `hostState` | string | no | Keep only the sessions whose host is `attached`, or only those whose host is `away` and its host grace running. Anything else is refused `validation`. Omit it for every host state, which is the only way to see a session whose host has never attached. |
| `clientSubject` | string | no | Keep only the sessions with a client of this subject in them, matched exactly. The host's own place does not count. Omit it for every session. Under a token it narrows inside the token's own sessions. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `sessions` | array of [SessionView](#sessionview) | One session per row, as far as this page goes. |
| `nextCursor` | string | Where the next page starts. Send it back as `cursor`. It is absent when this page is the last, so read the pages until it stops coming rather than until it is empty. |

#### Example query

```http
GET /v1/sessions?label=lobby%2014&limit=20
```

#### Example response

```json
{
  "sessions": [
    {
      "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
      "projectId": "proj_g1qr384an9asb4kdp10e",
      "state": "open",
      "label": "lobby 14",
      "openedAt": "1774000000000",
      "controllersHeld": 1
    }
  ]
}
```

### `POST /v1/sessions/boot`

Remove one subject from every session it is a client in, and free each place. Answers how many places were freed.

Who may call it: A secret key or organization key carrying `sessions:manage`, or a console sign-in.

Permission: `sessions:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string | yes | The subject to remove, matched exactly: the one its token was minted for. Every place it holds as a client in the project's open sessions is booted. |
| `reason` | string | no | Why the subject is being booted, from the same list a single client's boot takes. It reaches each of its clients as the reason its session ended. One of `BOOT_REASON_BOOTED`, `BOOT_REASON_IDLE`, `BOOT_REASON_POLICY`, `BOOT_REASON_KEY_UNVERIFIED`, `BOOT_REASON_RATE`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `booted` | number | How many places this call freed. 0 when the subject held none, which is what a repeat answers. |

#### Example request

```json
{
  "subject": "user_8123",
  "reason": "BOOT_REASON_POLICY"
}
```

#### Example response

```json
{
  "booted": 2
}
```

### `GET /v1/sessions/{sessionId}`

Read one session and the clients in it.

Who may call it: A host token, a resume token whose client holds `manage`, a secret key, organization key or token carrying `sessions:read`, or a console sign-in.

Permission: `sessions:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [SessionView](#sessionview) object, written out under **Objects**.

#### Example response

```json
{
  "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "state": "host_attached",
  "label": "lobby 14",
  "region": "us-central1",
  "openedAt": "1774000000000",
  "controllersHeld": 1,
  "participants": [
    {
      "participantId": "h_0mFkpdUNnY5zgH5t3k7Qgv",
      "opener": true,
      "seatClass": "opener",
      "seat": 1,
      "state": "connected",
      "presence": "PRESENCE_PRESENT",
      "joinedAt": "1774000000000"
    },
    {
      "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
      "seatClass": "controller",
      "seat": 1,
      "state": "connected",
      "presence": "PRESENCE_PRESENT",
      "grants": [
        "GRANT_SEND_INPUT",
        "GRANT_RECEIVE_BROADCAST"
      ],
      "joinedAt": "1774000030000",
      "transportPath": "turn"
    },
    {
      "participantId": "p_Vkoa0Y7116thO-TASN8MNY",
      "seatClass": "controller",
      "seat": 2,
      "state": "gone",
      "presence": "PRESENCE_AWAY",
      "joinedAt": "1774000120000",
      "goneReason": "background_budget"
    }
  ]
}
```

### `GET /v1/sessions/{sessionId}/meters`

Read the counters for a session and for each client in it. Nothing is written, and an ended session still answers for a while.

Who may call it: A host token, a resume token whose client holds `manage`, a secret key, organization key or token carrying `sessions:read`, or a console sign-in.

Permission: `sessions:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `sessionId` | string | The session these meters were read from. |
| `readAtMs` | string (64-bit integer) | When the counters were read, Unix ms on the service's clock. Every counter in this message is closed against this instant. |
| `session` | object, string keys to string | The session's own counters, keyed the way the meter store holds them, and nothing else. How the session ran: `epoch`, `seq`, `host_state`, `host_since_ms`, `attached_ms`, `away_ms`, `idle_ms`. Who came and went: `joins:attempted`, `joins:accepted`, `joins:refused`, `reconnects`, `resumes`, `grants_changes`, `host_attaches`, `host_resumes`. How many at once: `cur:{bundle}`, `peak:{bundle}`, `peak:participants`. Time in state: `pm:{bundle}:{state}`, `pg:{grant}:{state}`. Traffic: `msgs:{topic}:{dir}:{path}`, `bytes:{topic}:{dir}:{path}`, `turn:bytes_in`, `turn:bytes_out`, `turn:allocations`, `turn:grade`; the `{path}` in those two keys is the path the service recorded, `unknown` where none was reported. The same traffic by the transport the host reported it on, host-reported and never billed: `msgs:{topic}:{dir}:t:{transport}`, `bytes:{topic}:{dir}:t:{transport}`; `transports` carries these summed. What the host reported: `rtt:count`, `rtt:sum_ms`, `rtt:max_ms`, `stats_seq`. Every value is a string: a count is a decimal string because these are 64-bit numbers, and a field that records a state or a name, such as `host_state`, carries that word. |
| `participants` | array of [ParticipantMeters](#participantmeters) | One entry for the host and one for each client, each with its own counters, keyed the same way and nothing else: `state`, `bundle`, `grants`, `since_ms`, `joined_ms`, `ms:{state}`, `ms_bundle:{bundle}`, `ms_grant:{grant}`, `reconnects`, `grants_changes`, `resumed`, `device_class`, `os_family`, `network_class`, `country`, `path`, `path_since_ms`, `path_ms:{path}`, `path_changes`, `join_to_welcome_ms`, `turn:bytes_in`, `turn:bytes_out`, `turn:allocations`, `rtt:count`, `rtt:sum_ms`, `rtt:max_ms`, and its own `msgs:{topic}:{dir}` and `bytes:{topic}:{dir}`. `path` and `path_ms:{path}` carry the path this entry last reported in its stats, `unknown` until it reports one. |
| `transports` | array of [TransportTraffic](#transporttraffic) | The traffic the host reported on each transport, one entry per transport name, `server` first and then by name. These are the host's own figures, and every entry is a log. Only `server`, the traffic through the engine, is metered, and its meter is the engine's `engine:{measure}:{dir}` fields in `session`, never this entry; `peer` and a declared transport are logged, never metered. A session whose reports carried only `server` traffic has the one entry, and one with no reported traffic has none. |
| `control` | array of [ControlCount](#controlcount) | The control frames the session's clients sent the host, one entry per kind, by kind: presence changes, hellos, pings, heartbeats, transport setup and the rest. The host's log, never a meter: never billed, and no limit reads it. The control plane's meter is the engine's own count, `engine:ctl_msgs:{dir}` and `engine:ctl_bytes:{dir}` in `session`. |

#### Example response

```json
{
  "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
  "readAtMs": "1774000300000",
  "session": {
    "epoch": "1",
    "seq": "42",
    "host_state": "attached",
    "host_since_ms": "1774000000000",
    "attached_ms": "300000",
    "idle_ms": "0",
    "host_attaches": "1",
    "cur:controller": "1",
    "cur:viewer": "2",
    "pm:controller:connected": "280000",
    "pg:send_input:connected": "280000",
    "bytes:in:to_host:unknown": "512000"
  },
  "participants": [
    {
      "participantId": "h_Y9WDXoY5_Gq2Z2HnlYy8hy",
      "counters": {
        "bundle": "host",
        "state": "connected",
        "grants": "receive_input,broadcast,manage",
        "since_ms": "1774000000000",
        "joined_ms": "1774000000000",
        "ms:connected": "300000",
        "ms_bundle:host": "300000",
        "bytes:out:to_client": "480000"
      }
    },
    {
      "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
      "counters": {
        "bundle": "controller",
        "state": "connected",
        "grants": "send_input,receive_broadcast",
        "since_ms": "1774000020000",
        "joined_ms": "1774000020000",
        "path": "turn",
        "path_since_ms": "1774000020000",
        "path_ms:turn": "280000",
        "ms:connected": "280000",
        "ms_bundle:controller": "280000",
        "bytes:in:to_host": "240000"
      }
    }
  ]
}
```

### `POST /v1/sessions/{sessionId}/clients/{clientId}/boot`

Remove one client from the session and free its place.

Who may call it: A host token, a resume token whose client holds `manage`, a secret key, organization key or token carrying `sessions:manage`, or a console sign-in.

Permission: `sessions:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | no | Why this client is being booted. It reaches the client as the reason its session ended. One of `BOOT_REASON_BOOTED`, `BOOT_REASON_IDLE`, `BOOT_REASON_POLICY`, `BOOT_REASON_KEY_UNVERIFIED`, `BOOT_REASON_RATE`. |

#### Response body

The [ParticipantView](#participantview) object, written out under **Objects**.

#### Example request

```json
{
  "reason": "BOOT_REASON_BOOTED"
}
```

#### Example response

```json
{
  "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
  "seat": 1,
  "seatClass": "controller",
  "state": "gone"
}
```

### `PATCH /v1/sessions/{sessionId}/clients/{clientId}`

Replace what one client is allowed to do. Every client in the session is told.

Who may call it: A host token, a resume token whose client holds `manage`, a secret key, organization key or token carrying `sessions:manage`, or a console sign-in.

Permission: `sessions:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `grants` | array of string | yes | Replaces the full set; empty is refused. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |

#### Response body

The [ParticipantView](#participantview) object, written out under **Objects**.

#### Example request

```json
{
  "grants": [
    "GRANT_SEND_INPUT",
    "GRANT_RECEIVE_BROADCAST",
    "GRANT_MANAGE"
  ]
}
```

#### Example response

```json
{
  "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
  "seat": 1,
  "seatClass": "controller",
  "state": "connected",
  "grants": [
    "GRANT_SEND_INPUT",
    "GRANT_RECEIVE_BROADCAST",
    "GRANT_MANAGE"
  ]
}
```

## Tokens

Narrow a key to one subject for a while, and end one early.

### `POST /v1/tokens`

Mint a token: your key, narrowed to one `subject` and to a while. The token string is returned once, here, and never again.

Who may call it: A secret key or organization key carrying `tokens:mint`.

Permission: `tokens:mint`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string | yes | The developer's own user or device id, 1 to 128 characters. |
| `limits` | [TokenLimits](#tokenlimits) | no | Optional; the limits this token's presentations are held to. |
| `ttl` | string | no | How long the token is good for, as a duration string: `10m`, `1h`, `720h`. Leave it out for the default, `10m`. The ceiling is the project's `tokensMaxTtl`, and a token carrying `sessions:join` is held to `1h` whatever the project allows; above the ceiling that applies is refused `validation` with `details.field` `ttl`. |
| `sdk` | string | no | Same value as X-Bitskiff-SDK. |
| `permissions` | array of string | no | What this token may do, from the session words and no others: `sessions:open`, `sessions:read`, `sessions:manage`, `sessions:join`. Leave it out and the token carries `sessions:open`, which is the default. Any other word is refused `validation` with `details.field` `permissions`. |
| `sessionId` | string | no | Optional, sessions:join only: this session and no other. |
| `cnf` | [TokenConfirmation](#tokenconfirmation) | no | Optional, sessions:join only. |
| `grantsCeiling` | array of string | no | Optional, sessions:join only: the most the client this token admits may ever hold in the session. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `tokenId` | string | `tok_...`, the id GET and DELETE address it by. |
| `token` | string | `bsk_at_...`, returned once and stored only as its SHA-256. |
| `expiresAt` | string (64-bit integer) | Unix ms. |

#### Example request

```json
{
  "subject": "user_8123",
  "permissions": [
    "sessions:open"
  ],
  "ttl": "1h"
}
```

#### Example response

```json
{
  "tokenId": "tok_XFWqg3VKViwNuxz2",
  "token": "bsk_at_...",
  "expiresAt": "1774003600000"
}
```

### `GET /v1/tokens`

List the tokens the project holds, by id and paged. No token string is ever returned.

Who may call it: A secret key or organization key carrying `tokens:mint`.

Permission: `tokens:mint`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `subject` | string | no | Keep only the tokens minted for this subject. Omit it for every subject. |
| `keyId` | string | no | Keep only the tokens minted by this key. Omit it for every key. |
| `cursor` | string | no | Where to carry on from: the `nextCursor` the page before this one answered with. It is opaque, and it belongs to this listing and this project: one this service did not answer with, or one from another listing, is refused `validation` with `details.field` `cursor` rather than answering an empty page. |
| `limit` | number | no | How many tokens this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit` rather than shrunk. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `tokens` | array of [TokenSummary](#tokensummary) | One live token per row, and never a token string. |
| `nextCursor` | string | Where the next page starts. Send it back as `cursor`. It is absent when this page is the last, so read the pages until it stops coming rather than until it is empty. |

#### Example response

```json
{
  "tokens": [
    {
      "tokenId": "tok_XFWqg3VKViwNuxz2",
      "subject": "user_8123",
      "keyId": "key_ds7tq99qnn414r8g",
      "permissions": [
        "sessions:open"
      ],
      "mintedAt": "1774000000000",
      "expiresAt": "1774003600000"
    }
  ]
}
```

### `DELETE /v1/tokens/{tokenId}`

End one token. Sessions it opened keep running; close those yourself.

Who may call it: A secret key or organization key carrying `tokens:mint`.

Permission: `tokens:mint`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

None. The service answers `204`.

## Join

What a client calls with a join code, what a registered app calls with a handoff ticket, and what the handoff host page reads by the ticket's hash. These are the only routes a client reaches.

### `POST /v1/join/inspect`

What a client can show before it joins: whether the code is good, and how many clients can still join. It consumes nothing.

Who may call it: A publishable key.

Permission: `sessions:join`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes | The join code, as the client read it out of its link. |
| `sdk` | string | no | Which SDK is calling and its version, such as `ts-client/0.1.0`. |
| `origin` | string | no | Where this client says it is, declared once on the `BitskiffClient`: a URL or a custom scheme. |
| `reserve` | [JoinReserve](#joinreserve) | no | Gated joins: present to turn this fresh code into a join grant as well, so an entry survives a sign-in slower than the code's window. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `sessionId` | string | The session the code belongs to. |
| `state` | string | A joinable session state: "open" \| "host_attached" \| "host_away". |
| `clientsFree` | number | How many more clients this session will take, counted against its `maxClients`. A join page that is full can say so before it tries to join. |
| `region` | string | The region the session runs in. |
| `protocol` | number | The session's pinned version. |
| `maxClients` | number | The total `clientsFree` is counted against: SessionConfig.`maxClients` as the session answered it. |
| `e2ee` | boolean | End-to-end encryption: the session's own mode, fixed at open. |
| `requireToken` | string | Whether a join into this session needs a token: `off`, `token`, or `token_bound_to_session`. A join page reads it first, so a client without a token is told one is needed rather than refused `token_required`. |
| `grant` | [JoinGrant](#joingrant) | The join grant a request carrying `reserve` asked for; absent when none was asked for, and when the reservation was refused. |
| `reservationRefused` | [ReservationRefused](#reservationrefused) | Why a reservation was refused, when it was. |

#### Example request

```json
{
  "code": "1CAbCdEfG1HPWtg",
  "sdk": "ts-client/0.2.0"
}
```

#### Example response

```json
{
  "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
  "state": "open",
  "clientsFree": 1,
  "region": "us-central1",
  "protocol": 1,
  "maxClients": 2,
  "requireToken": "off"
}
```

### `POST /v1/join`

Take a join code, or the join grant inspect reserved from one, and answer the ticket a client connects on, with the resume token it comes back with.

Who may call it: A publishable key.

Permission: `sessions:join`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | no | The join code, as the client read it out of its link. |
| `grant` | [JoinGrantEntry](#joingrantentry) | no | The join grant `POST /v1/join/inspect` reserved from a fresh code, and its holder secret. |
| `protocol` | [ProtocolRange](#protocolrange) | no | The protocol versions the calling SDK can speak. A join is refused `protocol_unsupported` when the session's pinned version is outside this range. |
| `sdk` | string | no | Which SDK is calling and its version, such as `ts-client/0.1.0`. |
| `priorClientId` | string | no | Best-effort continuity hint; not an identity claim. |
| `e2ee` | boolean | no | The client's capability, never the fingerprint: on a session whose `e2ee` is true, a join sent with this false -- or from an sdk too old to carry the schedule -- is refused `e2ee_required` as a courtesy before any capacity is reserved. |
| `origin` | string | no | Where this client says it is: a URL or a custom scheme. The service reads this first and falls back to the `Origin` header only when it is empty, because a page inside an app shell sends `capacitor://localhost`, `http://localhost` or no origin at all. `POST /v1/join/inspect` takes the same field, and this is where a publishable key's origin list is enforced. |
| `environment` | [ClientEnvironment](#clientenvironment) | no | The program this client runs in, stated by its SDK (ClientEnvironment). |
| `metadata` | object, string keys to string | no | The developer's own metadata for this client (`join(code, { metadata })`), inside the client metadata bounds: over them is refused `validation` naming `metadata` or `metadata.<key>`, before any capacity is reserved. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `ticket` | [Ticket](#ticket) | What this client connects its transport with. |
| `resumeToken` | string | What this client presents to take its place back after a drop. Every resume rotates it. |
| `reconnectBudgetMs` | number | How long this client may spend reconnecting before its place is given up. |
| `backgroundBudgetMs` | number | How long this client may spend backgrounded before its place is given up. |
| `seatDeadlineMs` | string (64-bit integer) | Absolute Unix ms on the service's clock: the instant past which this resume token is refused and the client's place is gone. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered. |
| `limits` | [SessionLimits](#sessionlimits) | The caps this client is held to; a client holds no project key and so never calls the limits route. |
| `policyDeadlineMs` | string (64-bit integer) | Absolute Unix ms on the service's clock this client's soonest termination-policy deadline is due at, 0 when no rule is armed against it: the instant `ParticipantTransition.`policyDeadlineMs` tells the host, told to the client it concerns. |
| `registeredApps` | array of [RegisteredApp](#registeredapp) | The native apps the project registered, every platform's: the hosted join page reads the one for this client's platform off this answer and nowhere else, and shows the app offer only where one exists. |
| `handoffHint` | [HandoffHint](#handoffhint) | On the answer to `POST /v1/handoff` only: the page's transport as it stated it on the mint, absent when it stated none. |
| `handoffDataText` | string | The handoff data, passed as a string. |
| `handoffDataBytes` | string (base64) | The handoff data, passed as bytes. |
| `e2ee` | boolean | On the answer to `POST /v1/handoff` only: whether the place runs end-to-end encryption, the session's own fixed value (SessionView.e2ee). |
| `hostIdentityPublic` | string (base64) | On the answer to `POST /v1/handoff` only, for a place that encrypts: the host identity key the page stated on the mint (HandoffTicketRequest.`hostIdentityPublic`). |

#### Example request

```json
{
  "code": "1CAbCdEfG1HPWtg",
  "protocol": {
    "min": 1,
    "max": 1
  },
  "sdk": "ts-client/0.2.0"
}
```

#### Example response

```json
{
  "ticket": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
    "grants": [
      "GRANT_SEND_INPUT",
      "GRANT_RECEIVE_BROADCAST"
    ],
    "expiresAt": "1774000060000"
  },
  "resumeToken": "bsk_rt_...",
  "reconnectBudgetMs": 10000,
  "backgroundBudgetMs": 30000,
  "serverTimeMs": "1774000000000"
}
```

### `POST /v1/handoff`

The registered app redeems a handoff ticket and is answered as the same client; the place becomes the app's when its connection attaches.

Who may call it: A publishable key.

Permission: `sessions:join`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `ticket` | string | yes | The handoff ticket the page put in the launch link's fragment, read by the app and sent once. |
| `protocol` | [ProtocolRange](#protocolrange) | no | As JoinRequest.protocol: the protocol range this SDK speaks. |
| `sdk` | string | no | As JoinRequest.sdk: the SDK's name and version, for the inventory. |
| `origin` | string | no | As JoinRequest.origin: the app's declared origin, read first, the `Origin` header when empty. |
| `e2ee` | boolean | no | As JoinRequest.e2ee: the app's capability, never the fingerprint. |
| `keyThumbprint` | string | no | The RFC 7638 SHA-256 thumbprint of the X25519 key the app's `Hello` will present, the value `client.keyThumbprint()` gives. |
| `environment` | [ClientEnvironment](#clientenvironment) | no | As JoinRequest.environment: the app's own. |
| `metadata` | object, string keys to string | no | As JoinRequest.metadata: the app's metadata for the place, the place's at the commit when it is not empty; an empty map keeps the page's. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `ticket` | [Ticket](#ticket) | What this client connects its transport with. |
| `resumeToken` | string | What this client presents to take its place back after a drop. Every resume rotates it. |
| `reconnectBudgetMs` | number | How long this client may spend reconnecting before its place is given up. |
| `backgroundBudgetMs` | number | How long this client may spend backgrounded before its place is given up. |
| `seatDeadlineMs` | string (64-bit integer) | Absolute Unix ms on the service's clock: the instant past which this resume token is refused and the client's place is gone. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered. |
| `limits` | [SessionLimits](#sessionlimits) | The caps this client is held to; a client holds no project key and so never calls the limits route. |
| `policyDeadlineMs` | string (64-bit integer) | Absolute Unix ms on the service's clock this client's soonest termination-policy deadline is due at, 0 when no rule is armed against it: the instant `ParticipantTransition.`policyDeadlineMs` tells the host, told to the client it concerns. |
| `registeredApps` | array of [RegisteredApp](#registeredapp) | The native apps the project registered, every platform's: the hosted join page reads the one for this client's platform off this answer and nowhere else, and shows the app offer only where one exists. |
| `handoffHint` | [HandoffHint](#handoffhint) | On the answer to `POST /v1/handoff` only: the page's transport as it stated it on the mint, absent when it stated none. |
| `handoffDataText` | string | The handoff data, passed as a string. |
| `handoffDataBytes` | string (base64) | The handoff data, passed as bytes. |
| `e2ee` | boolean | On the answer to `POST /v1/handoff` only: whether the place runs end-to-end encryption, the session's own fixed value (SessionView.e2ee). |
| `hostIdentityPublic` | string (base64) | On the answer to `POST /v1/handoff` only, for a place that encrypts: the host identity key the page stated on the mint (HandoffTicketRequest.`hostIdentityPublic`). |

#### Example request

```json
{
  "ticket": "bsk_hf_...",
  "protocol": {
    "min": 1,
    "max": 1
  },
  "sdk": "android-client/0.2.0"
}
```

#### Example response

```json
{
  "ticket": {
    "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
    "participantId": "p_MWnCKAv7YT4UCIX8ncPLUA",
    "grants": [
      "GRANT_SEND_INPUT",
      "GRANT_RECEIVE_BROADCAST"
    ],
    "expiresAt": "1774000420000"
  },
  "resumeToken": "bsk_rt_...",
  "reconnectBudgetMs": 10000,
  "backgroundBudgetMs": 30000,
  "serverTimeMs": "1774000360000",
  "handoffHint": {
    "transport": "peer",
    "grade": "good",
    "rttMs": 12
  },
  "handoffDataText": "player-7"
}
```

### `GET /v1/handoff/{ticketHash}/apps`

The handoff host page reads the registered apps of the project a handoff ticket was minted for, by the ticket's hash, so it offers that project's app and no other.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `registeredApps` | array of [RegisteredApp](#registeredapp) | The project's registered apps, one per platform, with `clientKeyId` and `fingerprints` empty. |

#### Example response

```json
{
  "registeredApps": [
    {
      "platform": "android",
      "appId": "com.example.race",
      "handoffLink": "https://links.example.com/race/",
      "name": "Race",
      "gain": "Steer over Bluetooth"
    }
  ]
}
```

## Project keys

Create, change, rotate and revoke the keys a project signs in with.

### `GET /v1/project/keys`

List the project's keys. A secret key's string is truncated; a publishable key's is not.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `keys` | array of [KeyView](#keyview) | One key per row, each carrying `masked` rather than the key itself. A secret key is truncated there; a publishable key is stored and listed in clear, so its `masked` is the whole key. |

#### Example response

```json
{
  "keys": [
    {
      "keyId": "key_ds7tq99qnn414r8g",
      "type": "publishable",
      "projectId": "proj_g1qr384an9asb4kdp10e",
      "masked": "bsk_pk_hp5ymbh2...n3x1",
      "status": "active",
      "label": "web host",
      "origins": [
        "https://example.com"
      ],
      "createdAt": "1769990400000",
      "expiresAt": "1801526400000"
    }
  ]
}
```

### `POST /v1/project/keys`

Create a publishable or secret key. A secret key must name its permissions; a publishable one may not. The full key string is returned once, here, and never again.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string | yes | "publishable" \| "secret". |
| `label` | string | no | Your own name for the key. It is what the console and the audit rows show. |
| `origins` | array of string | no | The origins a publishable key may be used from. A page on any other origin is refused `origin_not_allowed`. |
| `limits` | [KeyLimits](#keylimits) | no | Caps this key is held to, each at or below the project's own. |
| `expiresAt` | string (64-bit integer) | no | When the key expires, Unix ms; 0 takes the default. |
| `permissions` | array of string | no | What a secret key may do, one name per route family, such as `sessions:open`. Required on a secret key and refused on a publishable one. Name this or `preset`, never both: sending both is refused `validation` with `details.field` `permissions`. [Credentials](/docs/guides/credentials/) lists every name. |
| `preset` | string | no | One of the console's ready-made permission lists instead of your own: `"full-access"` \| `"host-only"` \| `"monitor"`. Name this or `permissions`, never both. |
| `hostKeys` | array of string | no | A client key only: the ids of the host keys of the same project whose sessions it may join. |
| `subjects` | array of string | no | A secret key holding `tokens:mint` only: the subject prefixes a mint under it may name, as `display:`. |

#### Response body

The [KeyView](#keyview) object, written out under **Objects**.

#### Example request

```json
{
  "type": "secret",
  "label": "backend",
  "permissions": [
    "sessions:open",
    "tokens:mint"
  ]
}
```

#### Example response

```json
{
  "keyId": "key_hn76yrbsbdghwpdv",
  "type": "secret",
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "secret": "bsk_sk_...",
  "masked": "bsk_sk_tthy8dbr...nbn7",
  "status": "active",
  "label": "backend",
  "permissions": [
    "sessions:open",
    "tokens:mint"
  ],
  "createdAt": "1774000000000",
  "expiresAt": "1801526400000"
}
```

### `PATCH /v1/project/keys/{keyId}`

Change a key's label, origins, permissions, limits or expiry. Send only what you are changing.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `retireAt` | string (64-bit integer) | no | Move when a rotating key stops being accepted, Unix ms. Omit it to leave it alone. |
| `label` | string | no | A new name for the key. Omit it to leave the name alone. |
| `origins` | [StringListPatch](#stringlistpatch) | no | Replace the origin list. Omit it to leave the list alone; send it with no values to clear it. |
| `limits` | [KeyLimits](#keylimits) | no | New caps for the key. Each field applies on its own, and one left out is left alone. |
| `expiresAt` | string (64-bit integer) | no | Bring the key's expiry forward, Unix ms. It may only be lowered, and never past the expiry of the key making the change. Omit it to leave the expiry alone. |
| `permissions` | [StringListPatch](#stringlistpatch) | no | Secret keys only; validates exactly as `POST /project/keys` does. |
| `hostKeys` | [StringListPatch](#stringlistpatch) | no | A client key only, validated as the create does. |
| `preset` | string | no | A secret key's preset instead of `permissions` ("full-access", "host-only", "monitor"), expanded before the write; naming both is a `validation` refusal. |
| `subjects` | [StringListPatch](#stringlistpatch) | no | A secret key holding `tokens:mint` only, validated as the create does. |

#### Response body

The [KeyView](#keyview) object, written out under **Objects**.

#### Example request

```json
{
  "label": "backend (eu)",
  "origins": {
    "values": [
      "https://example.com"
    ]
  }
}
```

#### Example response

```json
{
  "keyId": "key_hn76yrbsbdghwpdv",
  "type": "secret",
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "masked": "bsk_sk_tthy8dbr...nbn7",
  "status": "active",
  "label": "backend (eu)"
}
```

### `DELETE /v1/project/keys/{keyId}`

Revoke a key. `reason=compromise` ends every session it opened at once; `reason=manual` lets live work drain and only stops the key authorizing anything new. Either way the revocation follows through to every key this one created.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `reason` | string | yes | `"manual"` \| `"compromise"`. There is no default: leaving it out, or sending anything else, is refused `validation` with `details.field` `reason`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `key` | [KeyView](#keyview) | The key as it now stands, revoked. |
| `sessionsEndedCount` | string (64-bit integer) | How many sessions this key had open were ended with it. |

#### Example response

```json
{
  "key": {
    "keyId": "key_hn76yrbsbdghwpdv",
    "type": "secret",
    "status": "revoked",
    "revokedAt": "1774000500000",
    "revokedReason": "compromise"
  },
  "sessionsEndedCount": "3"
}
```

### `POST /v1/project/keys/{keyId}/rotate`

Start a rotation. Answers the new key, and the old one keeps working until the overlap runs out.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `overlapS` | number | no | How many seconds the old key keeps being accepted for. Omit it to take the platform's own overlap. |

#### Response body

The [KeyView](#keyview) object, written out under **Objects**.

#### Example request

```json
{
  "overlapS": 86400
}
```

#### Example response

```json
{
  "keyId": "key_8cqgjhdz4hyy0dfe",
  "type": "secret",
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "secret": "bsk_sk_...",
  "masked": "bsk_sk_tthy8dbr...p878",
  "status": "active",
  "label": "backend",
  "createdAt": "1774000600000",
  "expiresAt": "1801526400000"
}
```

## Fixed codes

Make, list, relabel, replace and delete the join codes that do not rotate, for an NFC tag or a printed QR. A host names one by its id when it opens.

### `GET /v1/project/fixed-codes`

List the project's fixed codes: label, id and the session holding each one now. Never the code itself.

Who may call it: An organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `fixedCodes` | array of [FixedCodeView](#fixedcodeview) | This page's fixed codes, oldest first. |
| `nextCursor` | string | Empty when this page is the last. |

#### Example response

```json
{
  "fixedCodes": [
    {
      "id": "fc_3Qb8KfR2m0VxL7nT9pZc1w",
      "label": "front door",
      "sessionId": "s_O2SX-CMRO6O-R6XoN4ywTv",
      "createdAtMs": "1769990400000"
    }
  ]
}
```

### `POST /v1/project/fixed-codes`

Make a fixed code for an NFC tag or a printed QR. This answer is one of the two places the code is ever shown.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | no | At most 64 UTF-8 bytes; nothing resolves by it. |

#### Response body

The [FixedCodeView](#fixedcodeview) object, written out under **Objects**.

#### Example request

```json
{
  "label": "front door"
}
```

#### Example response

```json
{
  "id": "fc_3Qb8KfR2m0VxL7nT9pZc1w",
  "label": "front door",
  "code": "FCq8Rz2LmWp4Xk7",
  "createdAtMs": "1769990400000"
}
```

### `PATCH /v1/project/fixed-codes/{fixedCodeId}`

Change a fixed code's label.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | no | The new label, at most 64 UTF-8 bytes. |

#### Response body

The [FixedCodeView](#fixedcodeview) object, written out under **Objects**.

#### Example request

```json
{
  "label": "bar, left of the door"
}
```

#### Example response

```json
{
  "id": "fc_3Qb8KfR2m0VxL7nT9pZc1w",
  "label": "bar, left of the door",
  "createdAtMs": "1769990400000"
}
```

### `POST /v1/project/fixed-codes/{fixedCodeId}/replace`

Replace a fixed code: every copy of the old code stops working at once, and the id stays.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [FixedCodeView](#fixedcodeview) object, written out under **Objects**.

#### Example response

```json
{
  "id": "fc_3Qb8KfR2m0VxL7nT9pZc1w",
  "label": "front door",
  "code": "FCt5Ny9HdQ3sJw6",
  "createdAtMs": "1769990400000",
  "replacedAtMs": "1770076800000"
}
```

### `DELETE /v1/project/fixed-codes/{fixedCodeId}`

Delete a fixed code. Its code stops working; a session holding it keeps its clients.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

None. The service answers `204`.

## Project limits and settings

What the project allows, what it can open now, what it is set to, the raises waiting on an owner, and renaming or archiving it.

### `GET /v1/project/limits`

Read the project's limits: the ceilings, the concurrency gates and what is in use right now.

Who may call it: A secret key or organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `ceilings` | [SessionConfig](#sessionconfig) | The highest each session-config field may be opened with on this project. A duration of `0s` here is no ceiling at all, not a ceiling of zero: the field is unbounded, and a session may be opened with any value the grammar accepts. A request above a ceiling is clamped down to it rather than refused, and the session tells you what it was clamped to. |
| `maxConcurrentSessions` | number | The most sessions this project may hold open at once. |
| `maxConcurrentClients` | number | The most clients this project may hold at once. It is the number a join meets before a session's own `maxClients`. |
| `concurrentSessions` | number | How many sessions this project has open right now, counted against `maxConcurrentSessions`. |
| `concurrentClients` | number | How many clients this project holds right now, counted against `maxConcurrentClients`. A host does not count. |
| `sessionOpensPerMinute` | number | How many sessions this project may open in a minute. |
| `joinsPerMinuteSession` | number | The `joinsPerMinuteSession` row, spelled here the way `details.limit`, `LimitsPatch` and `bitskiff limits set` spell it. |
| `bytesPerSecond` | number | The one per-client byte cap: the `bytesPerSecond` row, per client per direction. |
| `clientMinutesPerDay` | [LimitQuota](#limitquota) | Day counter, resets 00:00 UTC. |
| `monthlySpendCap` | [LimitQuota](#limitquota) | The cap on what this project may spend in a month, and what is left of it. Spend is counted in rated units, one per client-minute, and the count rolls over with the month. |
| `turnBytesPerMonth` | [LimitQuota](#limitquota) | Bytes, month rollover. |
| `ticketMintsPerMinute` | [LimitQuota](#limitquota) | Key-scoped rate limit; `used` and `periodEnd` are 0. |
| `clientResumesPerMinute` | number | How many times one client may resume in a minute. |
| `statsReportsPerMinuteSession` | number | How many stats reports one session may post in a minute. |
| `tokensMaxTtl` | string | The longest a join token minted for this project may live, as a duration string -- `1h` unless the project has raised it, up to `720h`. It bounds the `ttl` a mint asks for and a live token's expiry, so lowering it shortens tokens already out. A key may hold itself to less, which is that key's own quota rather than this value. |
| `joinsPerMinuteIp` | number | How many joins this project admits from one address in a minute -- `20` unless the project has raised it, up to `200`. |
| `joinsPerMinuteKey` | number | The `joinsPerMinuteKey` row: joins one client key admits in a minute, across every session and address it joins from. |
| `messagesPerSecond` | number | The `messagesPerSecond` row's `bitskiff.in` rate: the project's own value, 120 unless the project has lowered it. |
| `linkMessagesPerSecond` | number | The `linkMessagesPerSecond` row: the link ceiling, the project's own value, equal to `messagesPerSecond` unless the project's plan raised it; a client key's own `limits.linkMessagesPerSecond` may sit under it. |
| `bootCooldown` | string | `bootCooldown`: how long a subject the host core booted with `rate` is refused every join into the project, as the duration string a person types (`10m` unless the project has lowered it, `0s` for none). |
| `requireToken` | string | Whether a join into this project needs a token: `off`, `token`, or `token_bound_to_session` -- `off` unless the project has set it. A key may require more for its own sessions, never less. |
| `samplePageSessions` | [SamplePageSessions](#samplepagesessions) | How many sessions this project has opened on the platform sample page, and the two bounds that count meets. Absent where the environment has no sample page. |

#### Example response

```json
{
  "maxConcurrentSessions": 5,
  "maxConcurrentClients": 40,
  "concurrentSessions": 3,
  "concurrentClients": 7,
  "sessionOpensPerMinute": 10,
  "joinsPerMinuteSession": 30,
  "bytesPerSecond": 8192,
  "clientMinutesPerDay": {
    "value": "2000",
    "scope": "project",
    "used": "740",
    "periodEnd": "1774051200000"
  },
  "requireToken": "off"
}
```

### `GET /v1/project`

Read the project the header names: its name, its slug, when it was created and its settings.

Who may call it: An organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

The [ProjectView](#projectview) object, written out under **Objects**.

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Acme demo",
  "slug": "acme-demo",
  "createdAt": "1769990400000",
  "joinUrl": "https://example.com/join",
  "e2ee": "off"
}
```

### `GET /v1/project/settings`

Read the project's settings: where a join link points, and whether end-to-end encryption is required.

Who may call it: An organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `joinUrl` | string | The page a join code points a client at. |
| `e2ee` | string | "off" \| "required". |
| `warnings` | array of string | Non-fatal notes, such as a joinUrl no key's allowlist covers. |
| `restrictedMode` | boolean | Publishable keys cannot open new sessions while this is on. |
| `terminationRules` | array of [TerminationRule](#terminationrule) | The project's default termination policy: the rule list a session takes when its open names none. |
| `sessionDefaults` | [SessionDefaults](#sessiondefaults) | The project's session defaults: the value each session-config field takes when an open leaves it out. |
| `registeredApps` | array of [RegisteredApp](#registeredapp) | The native apps the project registered, one per platform at most. |

#### Example response

```json
{
  "joinUrl": "https://example.com/join",
  "e2ee": "off"
}
```

### `PATCH /v1/project/settings`

Change the project's settings. Send only what you are changing; a join URL set to an empty string clears it.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `joinUrl` | string | no | A new join URL. Omit it to leave the setting alone. |
| `e2ee` | string | no | "off" \| "required". |
| `restrictedMode` | boolean | no | Reversible, and nothing live ends. |
| `terminationRules` | [TerminationRuleListPatch](#terminationrulelistpatch) | no | The project's default termination rule list, replaced whole. |
| `sessionDefaults` | [SessionDefaults](#sessiondefaults) | no | The project's session defaults, field by field: a field set here becomes that field's default and a field left empty (or 0 for `maxClients`) keeps the one it has. |
| `registeredApps` | [RegisteredAppListPatch](#registeredapplistpatch) | no | The registered apps, replaced whole. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `joinUrl` | string | The page a join code points a client at. |
| `e2ee` | string | "off" \| "required". |
| `warnings` | array of string | Non-fatal notes, such as a joinUrl no key's allowlist covers. |
| `restrictedMode` | boolean | Publishable keys cannot open new sessions while this is on. |
| `terminationRules` | array of [TerminationRule](#terminationrule) | The project's default termination policy: the rule list a session takes when its open names none. |
| `sessionDefaults` | [SessionDefaults](#sessiondefaults) | The project's session defaults: the value each session-config field takes when an open leaves it out. |
| `registeredApps` | array of [RegisteredApp](#registeredapp) | The native apps the project registered, one per platform at most. |

#### Example request

```json
{
  "joinUrl": "https://example.com/join"
}
```

#### Example response

```json
{
  "joinUrl": "https://example.com/join",
  "e2ee": "off"
}
```

### `GET /v1/project/capacity`

Read what the project can open now: its capacity standing, and how many more sessions of the shape you name, and the clients they hold, would be placed. A reading, not a reservation: every open still judges every limit.

Who may call it: A secret key or organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string, not in a body.

| Field | Type | Required | Description |
|---|---|---|---|
| `maxClients` | number | no | The clients each session holds; 0 reads the project's maxClients ceiling. |
| `lossyHz` | number | no | The summed maxHz of the lossy rows each session declares; 0 is none. |
| `reliableHz` | number | no | The summed maxHz of the reliable rows each session declares; 0 is none. |
| `maxBytes` | number | no | A message's ceiling on those rows; 0 is the wire ceiling, as an open row typing none. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `standing` | [CapacityNotice](#capacitynotice) | Always present; OK over an empty list when nothing stands. |
| `sessions` | number | How many more sessions of the asked shape would be placed now, one after another: the smaller of what the concurrency gates leave and what the service has room for. |
| `clients` | number | The clients those sessions hold, held to what concurrentClients leaves. |
| `maxClients` | number | The maxClients the reading priced each session at. |

#### Example query

```http
GET /v1/project/capacity?maxClients=4&lossyHz=30
```

#### Example response

```json
{
  "standing": {
    "status": "CAPACITY_STATUS_WARNING",
    "limits": [
      {
        "limit": "clientMinutesPerDay",
        "scope": "project",
        "scopeId": "proj_7f3k2m9xqw4tz8bn",
        "status": "CAPACITY_STATUS_WARNING",
        "value": "1640",
        "ceiling": "2000",
        "resetAt": "1774051200000"
      }
    ],
    "at": "1773990000000"
  },
  "sessions": 2,
  "clients": 8,
  "maxClients": 4
}
```

### `GET /v1/projects/{projectId}`

Read one project by its slug or id, archived or not.

Who may call it: An organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [ProjectView](#projectview) object, written out under **Objects**.

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Acme demo",
  "slug": "acme-demo",
  "createdAt": "1769990400000",
  "joinUrl": "https://example.com/join",
  "e2ee": "off"
}
```

### `PATCH /v1/projects/{projectId}`

Rename a project. The slug never changes.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | The project's new name, 1 to 64 characters. The slug stays as it is. |

#### Response body

The [ProjectView](#projectview) object, written out under **Objects**.

#### Example request

```json
{
  "name": "Acme lobby"
}
```

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Acme lobby",
  "slug": "acme-demo",
  "createdAt": "1769990400000",
  "joinUrl": "https://example.com/join",
  "e2ee": "off"
}
```

### `POST /v1/projects/{projectId}/archive`

Archive a project. Nothing is deleted: it refuses new sessions, keys and tokens, keeps its ledgers and usage, and an owner can unarchive it for 30 days.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [ProjectView](#projectview) object, written out under **Objects**.

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Acme demo",
  "slug": "acme-demo",
  "createdAt": "1769990400000",
  "joinUrl": "https://example.com/join",
  "e2ee": "off",
  "archivedAt": "1774915200000"
}
```

### `POST /v1/projects/{projectId}/unarchive`

Unarchive a project within 30 days of archiving it, so it takes new sessions, keys and tokens again.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [ProjectView](#projectview) object, written out under **Objects**.

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Acme demo",
  "slug": "acme-demo",
  "createdAt": "1769990400000",
  "joinUrl": "https://example.com/join",
  "e2ee": "off"
}
```

### `PUT /v1/project/limits`

Set the project's limits, within your plan. Lowering one takes effect at once and never ends live work.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `concurrentSessions` | number | no | A new ceiling on sessions open at once. Omit it to leave the current one. The response calls this ceiling `maxConcurrentSessions` and keeps `concurrentSessions` for the live count. |
| `concurrentClients` | number | no | A new ceiling on clients in this project at once. Omit it to leave the current one. The response calls this ceiling `maxConcurrentClients` and keeps `concurrentClients` for the live count. |
| `sessionOpensPerMinute` | number | no | A new ceiling on session opens per minute. Omit it to leave the current one. |
| `joinsPerMinuteSession` | number | no | A new ceiling on joins per minute for one session. Omit it to leave the current one. The response spells the same limit the same way. |
| `clientResumesPerMinute` | number | no | A new ceiling on resumes per minute for one client. Omit it to leave the current one. |
| `statsReportsPerMinuteSession` | number | no | A new ceiling on stats reports per minute for one session. Omit it to leave the current one. |
| `clientMinutesPerDay` | string (64-bit integer) | no | A new cap on client minutes a day. Omit it to leave the current one. |
| `monthlySpendCap` | string (64-bit integer) | no | A new monthly spend cap, in rated units. Omit it to leave the current one. |
| `turnBytesPerMonth` | string (64-bit integer) | no | A new cap on relayed bytes a month. Omit it to leave the current one. |
| `ticketMintsPerMinute` | number | no | A new ceiling on tickets minted per minute. Omit it to leave the current one. |
| `joinsPerMinuteKey` | number | no | A client key's join window, project-wide. |
| `messagesPerSecond` | number | no | The `bitskiff.in` rate, lowered only (at most 120). |
| `linkMessagesPerSecond` | number | no | The link ceiling, raised per plan. |
| `bootCooldown` | string | no | The `bootCooldown` row as a duration string (`5m`, `0s` for none), lowered only (at most `10m`); a value that is not a duration is `validation` naming the field. |
| `bytesPerSecond` | number | no | The byte row per client per direction, raised up to the plan's ceiling; a client key's own value sits under it. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `ceilings` | [SessionConfig](#sessionconfig) | The highest each session-config field may be opened with on this project. A duration of `0s` here is no ceiling at all, not a ceiling of zero: the field is unbounded, and a session may be opened with any value the grammar accepts. A request above a ceiling is clamped down to it rather than refused, and the session tells you what it was clamped to. |
| `maxConcurrentSessions` | number | The most sessions this project may hold open at once. |
| `maxConcurrentClients` | number | The most clients this project may hold at once. It is the number a join meets before a session's own `maxClients`. |
| `concurrentSessions` | number | How many sessions this project has open right now, counted against `maxConcurrentSessions`. |
| `concurrentClients` | number | How many clients this project holds right now, counted against `maxConcurrentClients`. A host does not count. |
| `sessionOpensPerMinute` | number | How many sessions this project may open in a minute. |
| `joinsPerMinuteSession` | number | The `joinsPerMinuteSession` row, spelled here the way `details.limit`, `LimitsPatch` and `bitskiff limits set` spell it. |
| `bytesPerSecond` | number | The one per-client byte cap: the `bytesPerSecond` row, per client per direction. |
| `clientMinutesPerDay` | [LimitQuota](#limitquota) | Day counter, resets 00:00 UTC. |
| `monthlySpendCap` | [LimitQuota](#limitquota) | The cap on what this project may spend in a month, and what is left of it. Spend is counted in rated units, one per client-minute, and the count rolls over with the month. |
| `turnBytesPerMonth` | [LimitQuota](#limitquota) | Bytes, month rollover. |
| `ticketMintsPerMinute` | [LimitQuota](#limitquota) | Key-scoped rate limit; `used` and `periodEnd` are 0. |
| `clientResumesPerMinute` | number | How many times one client may resume in a minute. |
| `statsReportsPerMinuteSession` | number | How many stats reports one session may post in a minute. |
| `tokensMaxTtl` | string | The longest a join token minted for this project may live, as a duration string -- `1h` unless the project has raised it, up to `720h`. It bounds the `ttl` a mint asks for and a live token's expiry, so lowering it shortens tokens already out. A key may hold itself to less, which is that key's own quota rather than this value. |
| `joinsPerMinuteIp` | number | How many joins this project admits from one address in a minute -- `20` unless the project has raised it, up to `200`. |
| `joinsPerMinuteKey` | number | The `joinsPerMinuteKey` row: joins one client key admits in a minute, across every session and address it joins from. |
| `messagesPerSecond` | number | The `messagesPerSecond` row's `bitskiff.in` rate: the project's own value, 120 unless the project has lowered it. |
| `linkMessagesPerSecond` | number | The `linkMessagesPerSecond` row: the link ceiling, the project's own value, equal to `messagesPerSecond` unless the project's plan raised it; a client key's own `limits.linkMessagesPerSecond` may sit under it. |
| `bootCooldown` | string | `bootCooldown`: how long a subject the host core booted with `rate` is refused every join into the project, as the duration string a person types (`10m` unless the project has lowered it, `0s` for none). |
| `requireToken` | string | Whether a join into this project needs a token: `off`, `token`, or `token_bound_to_session` -- `off` unless the project has set it. A key may require more for its own sessions, never less. |
| `samplePageSessions` | [SamplePageSessions](#samplepagesessions) | How many sessions this project has opened on the platform sample page, and the two bounds that count meets. Absent where the environment has no sample page. |

#### Example request

```json
{
  "concurrentSessions": 25,
  "sessionOpensPerMinute": 60
}
```

#### Example response

```json
{
  "maxConcurrentSessions": 25,
  "maxConcurrentClients": 40,
  "concurrentSessions": 3,
  "concurrentClients": 7,
  "sessionOpensPerMinute": 60,
  "joinsPerMinuteSession": 30,
  "bytesPerSecond": 8192
}
```

### `POST /v1/project/limits/raise`

Raise or lower one limit. A value within your plan's ceiling applies at once and takes effect on the next open or join; a value above it is held as a pending raise for an organization owner to approve or deny, and the current value applies meanwhile. A raise naming a fixed limit is refused `validation`. See [Raises](/docs/guides/limits/#raises).

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `limit` | string | yes | Which limit to raise, by the name a refusal carries in `details.limit`, such as `concurrentSessions`. |
| `value` | string (64-bit integer) | yes | What to raise it to. |
| `keyId` | string | no | Raise the limit on one key rather than on the project. Omit it for the project. |
| `word` | string | no | The value to set on a row that takes a word rather than a number, such as `token` for `requireToken`. Send it in place of `value`, never beside it. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `limit` | string | The limit this answer is about. |
| `value` | string (64-bit integer) | The value that was asked for. |
| `applied` | boolean | True when that value is in force now. It is true on every raise: no plan carries a ceiling, so nothing is held back. |
| `ceiling` | string (64-bit integer) | The plan ceiling that held the raise back, where a plan carries one. No plan carries one, so this field is never set. |
| `pending` | boolean | True where the raise was stored for a console user to resolve with the current value still in force. No plan carries a ceiling, so nothing is stored and this field is never set. |
| `word` | string | The word now in force, when the row takes a word. `value` is then `0`, and the raise is never pending. |

#### Example request

```json
{
  "limit": "concurrentSessions",
  "value": "50"
}
```

#### Example response

```json
{
  "limit": "concurrentSessions",
  "value": "50",
  "applied": false,
  "ceiling": "5",
  "pending": true
}
```

### `GET /v1/orgs/{orgId}/limit-raises`

List the pending limit raises across the organization's projects: each project's limit, the value it asked for, the value that applies meanwhile and the plan's ceiling.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `raises` | array of [LimitRaiseView](#limitraiseview) | The pending raises, by project and then by limit. |

#### Example response

```json
{
  "raises": [
    {
      "projectId": "proj_g1qr384an9asb4kdp10e",
      "projectName": "Arcade",
      "limit": "concurrentSessions",
      "value": "50",
      "current": "5",
      "ceiling": "5"
    }
  ]
}
```

### `POST /v1/orgs/{orgId}/limit-raises/approve`

Approve a pending limit raise: the value it asked for applies to the project from the next open or join, if it is within the organization's plan. Organization owners, or a key that manages the project.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `projectId` | string | yes | The project whose raise this resolves, as `proj_...`. |
| `limit` | string | yes | The limit in its parameter spelling (`concurrentSessions`), as the list names it. |
| `reason` | string | no | Why, in the owner's words, up to 500 characters: carried on the audit row and on the `limits.raise_approved` or `limits.raise_denied` event. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project whose raise was resolved. |
| `limit` | string | The limit in its parameter spelling. |
| `value` | string (64-bit integer) | The value the raise asked for, which an approve applied. |
| `approved` | boolean | `true` for an approve, which applied the value; `false` for a deny, which left the current value in place. |
| `reason` | string | The reason the owner gave, empty when none was. |

#### Example request

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "limit": "concurrentSessions",
  "reason": "the plan now covers it"
}
```

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "limit": "concurrentSessions",
  "value": "50",
  "approved": true,
  "reason": "the plan now covers it"
}
```

### `POST /v1/orgs/{orgId}/limit-raises/deny`

Deny a pending limit raise, with a reason: the request is cleared and the project's current value stands. Organization owners, or a key that manages the project.

Who may call it: An organization key carrying `project:manage`, or a console sign-in.

Permission: `project:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `projectId` | string | yes | The project whose raise this resolves, as `proj_...`. |
| `limit` | string | yes | The limit in its parameter spelling (`concurrentSessions`), as the list names it. |
| `reason` | string | yes | Why, in the owner's words, up to 500 characters: carried on the audit row and on the `limits.raise_approved` or `limits.raise_denied` event. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project whose raise was resolved. |
| `limit` | string | The limit in its parameter spelling. |
| `value` | string (64-bit integer) | The value the raise asked for, which an approve applied. |
| `approved` | boolean | `true` for an approve, which applied the value; `false` for a deny, which left the current value in place. |
| `reason` | string | The reason the owner gave, empty when none was. |

#### Example request

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "limit": "concurrentSessions",
  "reason": "not this month"
}
```

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "limit": "concurrentSessions",
  "value": "50",
  "reason": "not this month"
}
```

## Usage

What the project has used, by day or by month, and the usage records you bill your own customers on.

### `GET /v1/project/usage`

Usage by day or by month over a range, for every meter or for one of them.

Who may call it: A secret key or organization key carrying `usage:read`, or a console sign-in.

Permission: `usage:read`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string, not in a body.

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string (64-bit integer) | yes | Ms since epoch, inclusive. |
| `to` | string (64-bit integer) | yes | Ms since epoch, exclusive. |
| `granularity` | string | no | "day" \| "month"; the two grains the rollups store. |
| `meter` | string | no | Optional meter id filter; empty means every meter in the bucket. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `rows` | array of [UsageRow](#usagerow) | One row per bucket in the window that was asked for. |
| `frozenThrough` | string (64-bit integer) | The start of the last bucket the rollups have frozen for this grain, ms since epoch, or 0 when they have frozen nothing yet. |

#### Example query

```http
GET /v1/project/usage?from=1772323200000&to=1774915200000&granularity=month
```

#### Example response

```json
{
  "rows": [
    {
      "bucketStart": "1772323200000",
      "meters": {
        "sessions.opened": 412,
        "participant_minutes": 4820
      }
    }
  ],
  "frozenThrough": "1774828800000"
}
```

### `GET /v1/project/ledger`

Usage records over a range, for every subject or for one, as JSON or CSV: one per ended session, one per session per UTC day, or one per ended client. Paged.

Who may call it: A secret key or organization key carrying `usage:read`, or a console sign-in.

Permission: `usage:read`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string, not in a body.

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string (64-bit integer) | yes | The start of the range, Unix ms, inclusive, on the slice's own instant: `endedAt` for `session`, `day` for `day`, `goneAt` for `client`. Required. |
| `to` | string (64-bit integer) | yes | The end of the range, Unix ms, exclusive. Required, after `from`, and at most 31 days after it: a longer range, or one that would read more of the ledger than one read may, is refused `validation` with `details.field` `to`, so read the period in parts. |
| `slice` | string | no | Which records: `session` (the default), one per ended session; `day`, one per session per UTC day it was alive, which is what a session that outlives an invoice period is billed by; or `client`, one per ended client. Anything else is refused `validation`. |
| `subject` | string | no | Keep only the records of this subject, exactly: the opener's on a session or day record, the client's own on a client record. Omit it for every record of the project. |
| `cursor` | string | no | Where to carry on from: the `nextCursor` of the page before this one, sent with the same range, slice and subject. It reads the first page's finished result, so it runs no new read, and it lasts 24 hours. One this service did not answer with, one for another filter, or one past its 24 hours is refused `validation` with `details.field` `cursor`. |
| `limit` | number | no | How many records this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit`. |
| `format` | string | no | `json` (the default) or `csv`. CSV answers `text/csv`: a header row of the record's field names, then one row per record, every cell quoted, and a cell that starts `=`, `+`, `-`, `@`, a tab or a carriage return prefixed with an apostrophe. The maps are one JSON object per cell, and the next cursor rides the `X-Bitskiff-Next-Cursor` header, absent on the last page. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `sessions` | array of [SessionUsage](#sessionusage) | The session or day records of this page, in the slice's instant order and then by session. Answered for `slice` `session` and `day`. |
| `clients` | array of [ClientUsage](#clientusage) | The client records of this page, by `goneAt` and then by session and client. Answered for `slice` `client`. |
| `nextCursor` | string | Where the next page starts. Send it back as `cursor` with the same range, slice and subject. It is absent when this page is the last. |
| `frozenThrough` | string (64-bit integer) | Unix ms before which no record of the slice changes any more. Bill a period once this has passed its `to`; before that the answer is a running figure, and `writtenAt` on each record says when it last moved. |

#### Example query

```http
GET /v1/project/ledger?from=1772323200000&to=1774915200000&slice=session&subject=tenant_42&limit=1
```

#### Example response

```json
{
  "sessions": [
    {
      "id": "k7rw2m5xq3dzt4pnvb6hy2cjle",
      "sessionId": "s_Yk3dQ9wLr2Tn8vBx5Mz1Ca",
      "projectId": "proj_7hq2m9xk4rz3tv8bn6cw",
      "region": "us-central1",
      "keyId": "key_y090cqev9fn21nxp",
      "subject": "tenant_42",
      "label": "lobby-north",
      "metadata": {
        "venue": "north"
      },
      "openedAt": "1772400000000",
      "endedAt": "1772403600000",
      "endedReason": "host_closed",
      "attachedMs": "3540000",
      "detachedMs": "60000",
      "seatMs": "9720000",
      "seatMsByBundle": {
        "controller": "7200000",
        "viewer": "2520000"
      },
      "clients": 4,
      "peakClients": 3,
      "bytesReported": "18432000",
      "dataBytesDown": "15728640",
      "dataBytesUp": "2097152",
      "dataBytesGrade": "server_measured",
      "writtenAt": "1772404200000",
      "writerRevision": 3
    }
  ],
  "nextCursor": "YzEKbGVkZ2VyCnByb2pfN2hxMm05eGs0cnozdHY4Ym42Y3cKeyJqIjoiam9iX2FiIn0",
  "frozenThrough": "1774656000000"
}
```

## Sign-in

How the console signs a person in. These take a console session rather than a key, and no SDK calls them.

### `POST /v1/auth/refresh`

Rotate the console's refresh cookie and answer a fresh console sign-in token.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `token` | string | `bsk_cs_...`, presented as `Authorization: Bearer`. |
| `expiresAt` | string (64-bit integer) | Unix ms; `identity.consoleTokenTtlS` after issue. |
| `userId` | string | The signed-in person's id. |
| `mfaAt` | string (64-bit integer) | Unix ms this user last passed MFA; 0 when they never have. |

#### Example response

```json
{
  "token": "bsk_cs_...",
  "expiresAt": "1774000900000",
  "userId": "user_2737116ccba906a9d2229ddfdbf5b812"
}
```

### `GET /v1/auth/principal`

Who this console sign-in is, and the organizations and projects that console user holds a relation in.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `userId` | string | The signed-in person's id. |
| `email` | string | Absent until a console user record carries an address; the console sign-in alone does not. |
| `mfaAt` | string (64-bit integer) | Unix ms this user last passed MFA; 0 when they never have. |
| `organizations` | array of [OrganizationMembership](#organizationmembership) | Every organization this person holds a relation in, with the highest relation they hold in each. |
| `mfaGraceEndsAt` | string (64-bit integer) | When this person's second-factor grace ends, in Unix ms: after it, every route but sign-in, enrolment and sign-out refuses them `mfa_required`. Absent when no organization holds them to a second factor or this session passed one. |
| `projects` | array of [ProjectMembership](#projectmembership) | Every project this person holds a relation on, directly or through their organization, with each project relation they hold there. |

#### Example response

```json
{
  "userId": "user_2737116ccba906a9d2229ddfdbf5b812",
  "email": "dev@example.com",
  "organizations": [
    {
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "name": "Northwind",
      "relation": "member"
    }
  ],
  "projects": [
    {
      "projectId": "proj_g1qr384an9asb4kdp10e",
      "slug": "acme-demo",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "relations": [
        "key_manager",
        "editor",
        "viewer"
      ]
    }
  ]
}
```

### `POST /v1/auth/bootstrap`

Claim the first organization on a service that holds none.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `orgName` | string | no | Empty takes the default name. |
| `projectName` | string | no | Empty takes the default name. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization the claim created. |
| `projectId` | string | The first project in it. |

#### Example request

```json
{
  "orgName": "Northwind",
  "projectName": "Lobby"
}
```

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "projectId": "proj_g1qr384an9asb4kdp10e"
}
```

### `POST /v1/auth/sign-out`

End the console sign-in and clear its cookie.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

None. The service answers `204`.

### `POST /v1/auth/sign-in/link`

Mail a single-use sign-in link. The answer is the same whether or not the address is known.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | no | The address to mail the sign-in link to. |

#### Response body

None. The service answers `204`.

#### Example request

```json
{
  "email": "dev@example.com"
}
```

### `POST /v1/auth/sign-in/link/verify`

Exchange the token out of a mailed sign-in link for a console sign-in, once.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | no | The token out of the mailed link. It is good once. |
| `idToken` | string | no | The Identity Platform ID token the console holds after completing the link (and the second factor, when one is enrolled) with the web SDK. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `token` | string | `bsk_cs_...`, presented as `Authorization: Bearer`. |
| `expiresAt` | string (64-bit integer) | Unix ms; `identity.consoleTokenTtlS` after issue. |
| `userId` | string | The signed-in person's id. |
| `mfaAt` | string (64-bit integer) | Unix ms this user last passed MFA; 0 when they never have. |

#### Example request

```json
{
  "token": "5m8qTzX..."
}
```

#### Example response

```json
{
  "token": "bsk_cs_...",
  "expiresAt": "1774000900000",
  "userId": "user_2737116ccba906a9d2229ddfdbf5b812"
}
```

### `GET /v1/auth/sign-in/oidc/{provider}`

Start a Google or Microsoft sign-in without the web SDK: the authorization URL, and a state cookie.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `url` | string | The provider's authorization URL, carrying the state and the PKCE `S256` challenge. |

#### Example response

```json
{
  "url": "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&scope=openid+email+profile&state=Qm9...&code_challenge=E9Mr...&code_challenge_method=S256"
}
```

### `POST /v1/auth/sign-in/oidc/{provider}/callback`

Finish a Google or Microsoft sign-in: the popup's two ID tokens for a console sign-in.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `idToken` | string | no | The Identity Platform ID token the popup answered. |
| `providerIdToken` | string | no | The provider's own ID token the popup answered (`credential.idToken`). |
| `code` | string | no | The authorization code, on the redirect shape only; empty on the popup shape. |
| `state` | string | no | The state the authorization URL carried, on the redirect shape only; it must equal the state cookie's. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `token` | string | `bsk_cs_...`, presented as `Authorization: Bearer`. |
| `expiresAt` | string (64-bit integer) | Unix ms; `identity.consoleTokenTtlS` after issue. |
| `userId` | string | The signed-in person's id. |
| `mfaAt` | string (64-bit integer) | Unix ms this user last passed MFA; 0 when they never have. |

#### Example request

```json
{
  "idToken": "eyJhbGciOi...",
  "providerIdToken": "eyJhbGciOi..."
}
```

#### Example response

```json
{
  "token": "bsk_cs_...",
  "expiresAt": "1774000900000",
  "userId": "user_2737116ccba906a9d2229ddfdbf5b812"
}
```

### `GET /v1/auth/sign-in/providers`

Which federated providers this deployment signs in with: Google and Microsoft, each enabled or not.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `providers` | array of [SignInProvider](#signinprovider) | One row per federated provider of the private MVP, `google` then `microsoft`. |

#### Example response

```json
{
  "providers": [
    {
      "name": "google",
      "enabled": true
    },
    {
      "name": "microsoft",
      "enabled": false
    }
  ]
}
```

### `POST /v1/auth/step-up`

Pass the second factor again, so the console sign-in may do a step-up act.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `idToken` | string | no | The Identity Platform ID token the console holds after passing the factor again with the web SDK. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `token` | string | `bsk_cs_...`, presented as `Authorization: Bearer`. |
| `expiresAt` | string (64-bit integer) | Unix ms; `identity.consoleTokenTtlS` after issue. |
| `userId` | string | The signed-in person's id. |
| `mfaAt` | string (64-bit integer) | Unix ms this user last passed MFA; 0 when they never have. |

#### Example request

```json
{
  "idToken": "eyJhbGciOi..."
}
```

#### Example response

```json
{
  "token": "bsk_cs_...",
  "expiresAt": "1774000900000",
  "userId": "user_2737116ccba906a9d2229ddfdbf5b812",
  "mfaAt": "1773999990000"
}
```

### `POST /v1/auth/device/code`

Start a terminal's sign-in: a device code to poll with, and a short code the person compares.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `deviceCode` | string | The secret the terminal polls with. |
| `userCode` | string | The short code the human compares against the one on the approval page. |
| `verificationUri` | string | The console page to open, with `userCode` already in it: one URL on one line, so the person has no second address to compare. |
| `expiresIn` | number | Seconds this code is good for: `identity.deviceCodeTtlS` = 600. |
| `interval` | number | Seconds between polls: `identity.devicePollIntervalS` = 5. |

#### Example response

```json
{
  "deviceCode": "kR3v8Yq...",
  "userCode": "BSKF-4QT9",
  "verificationUri": "https://console.example.com/auth/device?code=BSKF-4QT9",
  "expiresIn": 600,
  "interval": 5
}
```

### `POST /v1/auth/device/token`

Poll a device code. It answers pending until somebody approves, then the console credentials.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `deviceCode` | string | no | The `deviceCode` of the answer above, sent back unchanged. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `token` | string | `bsk_cs_...`, presented as `Authorization: Bearer`. |
| `expiresAt` | string (64-bit integer) | Unix ms; `identity.consoleTokenTtlS` after issue. |
| `userId` | string | The signed-in person's id. |
| `mfaAt` | string (64-bit integer) | Unix ms this user last passed MFA; 0 when they never have. |

#### Example request

```json
{
  "deviceCode": "kR3v8Yq..."
}
```

#### Example response

```json
{
  "token": "bsk_cs_...",
  "expiresAt": "1774000900000",
  "userId": "user_2737116ccba906a9d2229ddfdbf5b812"
}
```

### `POST /v1/auth/device/approve`

Approve or cancel a terminal's sign-in, from the console page the person opened.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `userCode` | string | no | The short code the approval page is showing, which the person compared against the one in the terminal. |
| `decision` | string | no | `approve` or `deny`, and nothing else: any other word is a `validation` refusal naming `decision`. |

#### Response body

None. The service answers `204`.

#### Example request

```json
{
  "userCode": "BSKF-4QT9",
  "decision": "approve"
}
```

### `GET /v1/auth/device/{userCode}`

Read a pending device code's time left, for the approval page's countdown.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `expiresIn` | number | Seconds until the pending device code expires (RFC 8628, CALL-1192); an approved or spent code answers invalid_key instead. |

#### Example response

```json
{
  "expiresIn": 540
}
```

### `POST /v1/platform/mfa-resets`

Remove the second factor of a console user who lost the device that holds it, and end their console sign-ins. Platform staff only, signed in with a fresh factor.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `orgId` | string | yes | The organization the console user is a member of, as `org_...`. |
| `email` | string | yes | The console user's address. |

#### Response body

None. The service answers `204`.

#### Example request

```json
{
  "orgId": "org_a14xc28ymkn7tx9qz8pv",
  "email": "dev@example.com"
}
```

## Organizations

The organization a project belongs to, its projects, its organization keys, its plan and its policies.

### `GET /v1/orgs/{orgId}`

Read one organization. The caller has to hold a relation on it.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization's id. |
| `name` | string | The organization's name, as it is shown in the console. |
| `createdAt` | string (64-bit integer) | Unix ms. |

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Northwind",
  "createdAt": "1774000000000"
}
```

### `PATCH /v1/orgs/{orgId}`

Rename an organization. Organization owners and admins only.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | The organization's new name, 1-64 characters after trimming. |

#### Response body

The [OrgView](#orgview) object, written out under **Objects**.

#### Example request

```json
{
  "name": "Northwind Games"
}
```

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Northwind Games",
  "createdAt": "1774000000000"
}
```

### `GET /v1/orgs/{orgId}/projects`

List the organization's projects, with their settings.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `projects` | array of [ProjectView](#projectview) | One project per row. |

#### Example response

```json
{
  "projects": [
    {
      "projectId": "proj_g1qr384an9asb4kdp10e",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "name": "Acme demo",
      "slug": "acme-demo",
      "createdAt": "1769990400000"
    }
  ]
}
```

### `POST /v1/orgs/{orgId}/projects`

Create a project, with the default settings and limits. Answers the project, including the slug it will be called by.

Who may call it: An organization key carrying `projects:create`, or a console sign-in.

Permission: `projects:create`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Your own name for the project, 1 to 64 characters. The slug is made from it. |
| `slug` | string | no | The public handle to claim. |

#### Response body

The [ProjectView](#projectview) object, written out under **Objects**.

#### Example request

```json
{
  "name": "Acme demo",
  "slug": "acme-demo"
}
```

#### Example response

```json
{
  "projectId": "proj_g1qr384an9asb4kdp10e",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Acme demo",
  "slug": "acme-demo",
  "createdAt": "1774000000000",
  "e2ee": "off"
}
```

### `GET /v1/orgs/{orgId}/admin-keys`

List the organization keys, masked, each with its expiry.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `keys` | array of [KeyView](#keyview) | One organization key per row. A key's full string is never listed. |

#### Example response

```json
{
  "keys": [
    {
      "keyId": "key_r227gb9v7n49gcez",
      "type": "admin",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "masked": "bsk_ak_j2vzz9nz...najb",
      "status": "active",
      "label": "ci",
      "createdAt": "1769990400000",
      "expiresAt": "1777766400000"
    }
  ]
}
```

### `POST /v1/orgs/{orgId}/admin-keys`

Create an organization key on the organization's service account.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | no | A name for the key, shown wherever the key is listed. |
| `projects` | array of string | no | Empty means every project in the organization. |
| `expiresAt` | string (64-bit integer) | no | When the key expires, Unix ms. |
| `permissions` | array of string | yes | The permission list the organization key holds, from the one vocabulary: the list is the fence the role used to stand in for. |
| `subjects` | array of string | no | An organization key holding `tokens:mint` only, validated as `CreateKeyRequest.subjects` is: the subject prefixes a mint under it may name, as `display:`. |
| `login` | boolean | no | True when `bitskiff login` or `bitskiff init` makes the login key after a person approved the sign-in: the key has no expiry unless `expiresAt` names one, and records that person as its approver. |

#### Response body

The [KeyView](#keyview) object, written out under **Objects**.

#### Example request

```json
{
  "label": "console",
  "projects": [
    "proj_g1qr384an9asb4kdp10e"
  ],
  "permissions": [
    "sessions:open",
    "project:read"
  ]
}
```

#### Example response

```json
{
  "keyId": "key_r227gb9v7n49gcez",
  "type": "admin",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "projects": [
    "proj_g1qr384an9asb4kdp10e"
  ],
  "secret": "bsk_ak_...",
  "masked": "bsk_ak_j2vzz9nz...najb",
  "status": "active",
  "label": "console",
  "permissions": [
    "sessions:open",
    "project:read"
  ],
  "createdAt": "1774000000000"
}
```

### `DELETE /v1/orgs/{orgId}/admin-keys/{keyId}`

Revoke an organization key before it expires. The revocation follows through to every key it created: `reason=manual` lets their live work drain, `reason=compromise` ends every session they opened at once.

Who may call it: An organization key carrying `keys:manage`, or a console sign-in.

Permission: `keys:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `reason` | string | no | `"manual"` (the default) \| `"compromise"`. Anything else is refused `validation` with `details.field` `reason`. |

#### Response body

The [KeyView](#keyview) object, written out under **Objects**.

#### Example response

```json
{
  "keyId": "key_r227gb9v7n49gcez",
  "type": "admin",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "projects": [
    "proj_g1qr384an9asb4kdp10e"
  ],
  "masked": "bsk_ak_j2vzz9nz...najb",
  "status": "revoked",
  "label": "console",
  "createdAt": "1774000000000",
  "revokedAt": "1774000500000"
}
```

### `GET /v1/orgs`

List the organizations the signed-in console user is a member of.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgs` | array of [OrgView](#orgview) | The organizations the signed-in person is a member of, newest first. |
| `staff` | boolean | True when the caller is an organization key that holds the staff role. Only the key holder reads it, and a console session never sees it set. `bitskiff login` refuses a key for which it is true. |

#### Example response

```json
{
  "orgs": [
    {
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "name": "Northwind",
      "createdAt": "1774000000000"
    }
  ]
}
```

### `POST /v1/orgs`

Create an organization.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no | The name to give the organization. |
| `plan` | [PlanRequest](#planrequest) | no | The organization's plan, required: staff name the plan every organization they create directly is bounded by. |

#### Response body

The [OrgView](#orgview) object, written out under **Objects**.

#### Example request

```json
{
  "name": "Northwind"
}
```

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Northwind",
  "createdAt": "1774000000000"
}
```

### `GET /v1/orgs/{orgId}/plan`

Read the organization's plan: its name, its ceilings, and when it expires.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization the plan is on. |
| `plan` | [OrgPlan](#orgplan) | The plan on the organization's record: its name, its ceilings, and when it expires. |
| `expired` | boolean | Whether the plan has passed its expiry, read against the clock when the request was answered. |

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "plan": {
    "name": "experimental",
    "ceilings": {
      "concurrentSessions": "5"
    },
    "expiresAt": "1776592000000",
    "setAt": "1774000000000",
    "setBy": "user_2737116ccba906a9d2229ddfdbf5b812"
  }
}
```

### `PUT /v1/orgs/{orgId}/plan`

Set or renew the organization's plan. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | The named plan to set. Its template resolves into values on the organization's record. |
| `ceilings` | object, string keys to string (64-bit integer) | no | Overrides on the template, keyed by the catalogue parameter (`concurrentSessions`), the one spelling on every wire surface; only rows a project may raise. |
| `expiresAt` | string (64-bit integer) | no | Unix ms. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization the plan is on. |
| `plan` | [OrgPlan](#orgplan) | The plan on the organization's record: its name, its ceilings, and when it expires. |
| `expired` | boolean | Whether the plan has passed its expiry, read against the clock when the request was answered. |

#### Example request

```json
{
  "name": "experimental",
  "ceilings": {
    "concurrentSessions": "5"
  },
  "expiresAt": "1776592000000"
}
```

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "plan": {
    "name": "experimental",
    "ceilings": {
      "concurrentSessions": "5"
    },
    "expiresAt": "1776592000000",
    "setAt": "1774000000000",
    "setBy": "user_2737116ccba906a9d2229ddfdbf5b812"
  }
}
```

### `GET /v1/orgs/{orgId}/policies`

Read the organization's policies: whether every member must enrol a second factor, whether platform staff may read its records, who makes organization keys, how long one may live, and whether a CLI sign-in acts here.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization in the path, as `org_...`. |
| `mfaRequired` | boolean | Whether every member must enrol a second factor; `false` by default. |
| `staffAccess` | string | `allowed` (the default) or `refused`. |
| `orgKeyMinting` | string | `keys` (the default) or `console`. |
| `keyMaxLifetime` | string | The longest an organization key may live, as a duration; absent when none is set. |
| `deviceSignIn` | string | `allowed` (the default) or `refused`. |
| `providerSecondFactor` | string | `ignore` (the default) or `accept`: whether a provider's second factor counts in place of Bitskiff's. |

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "staffAccess": "allowed"
}
```

### `PUT /v1/orgs/{orgId}/policies`

Set the organization's policies: whether every member must enrol a second factor, whether platform staff may read its records, who makes organization keys, how long one may live, and whether a CLI sign-in acts here. Organization owners only.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `mfaRequired` | boolean | no | Whether every member must enrol a second factor. |
| `staffAccess` | string | yes | Whether platform staff may read the organization's records: `allowed` or `refused`. |
| `orgKeyMinting` | string | no | Who makes organization keys: `keys` (a key holding `keys:manage`, or a console sign-in) or `console` (a console sign-in only). |
| `keyMaxLifetime` | string | no | The longest an organization key made or rotated from now on may live, as a duration (`2160h`), at most a year; empty clears it. |
| `deviceSignIn` | string | no | Whether a console sign-in a device approval issued (`bitskiff login`, `bitskiff init`) acts in this organization: `allowed` or `refused`. |
| `providerSecondFactor` | string | no | Whether a second factor a Google or Microsoft sign-in asserts (its `amr` holds `mfa`) counts in place of Bitskiff's own: `ignore` (the default) or `accept`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization in the path, as `org_...`. |
| `mfaRequired` | boolean | Whether every member must enrol a second factor; `false` by default. |
| `staffAccess` | string | `allowed` (the default) or `refused`. |
| `orgKeyMinting` | string | `keys` (the default) or `console`. |
| `keyMaxLifetime` | string | The longest an organization key may live, as a duration; absent when none is set. |
| `deviceSignIn` | string | `allowed` (the default) or `refused`. |
| `providerSecondFactor` | string | `ignore` (the default) or `accept`: whether a provider's second factor counts in place of Bitskiff's. |

#### Example request

```json
{
  "mfaRequired": true,
  "staffAccess": "refused"
}
```

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "mfaRequired": true,
  "staffAccess": "refused"
}
```

### `GET /v1/platform/orgs`

Every organization on the platform, with its plan, its owners' addresses, its project count and what it is running now. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `cursor` | string | no | Where to carry on from: the `nextCursor` the page before this one answered with. It is opaque, and it belongs to this listing: one this service did not answer with, or one from another listing, is refused `validation` with `details.field` `cursor` rather than answering an empty page. |
| `limit` | number | no | How many organizations this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit` rather than shrunk. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgs` | array of [PlatformOrgView](#platformorgview) | This page of organizations, ordered by id. |
| `nextCursor` | string | Empty when this page is the last. |
| `committedClientSlots` | number | The client slots every live plan commits, summed over every organization and not only this page. |
| `unboundedLivePlans` | number | Live plans that bound no clients, so commit no counted slots. |
| `platformSlots` | number | The platform ceiling's slots, the number `committedClientSlots` is read against. |
| `refusedOrgs` | number | Organizations whose owner refused staff access: counted over every organization, never listed on any page. |

#### Example response

```json
{
  "orgs": [
    {
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "name": "Acme Games",
      "createdAt": "1774000000000",
      "ownerEmails": [
        "owner@example.com"
      ],
      "plan": {
        "name": "experimental",
        "ceilings": {
          "concurrentSessions": "5"
        },
        "expiresAt": "1776592000000",
        "setAt": "1774000000000",
        "setBy": "user_2737116ccba906a9d2229ddfdbf5b812"
      },
      "projectCount": 2,
      "liveSessions": 1,
      "liveClients": 4,
      "clientSlots": 40,
      "platformSlots": 1000
    }
  ],
  "committedClientSlots": 40,
  "unboundedLivePlans": 0,
  "platformSlots": 1000
}
```

### `GET /v1/platform/orgs/{orgId}/usage`

One organization's month as cost per leg: the quantity, the unit cost to Bitskiff and their product, each labelled by what it is. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `month` | string | no | The UTC month to read, as `YYYY-MM`. Omit it for the current month. Anything else is refused `validation` with `details.field` `month`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization, as `org_...`. |
| `month` | string | The UTC month read, as YYYY-MM. |
| `projectCount` | number | How many projects were summed. |
| `legs` | array of [PlatformCostLeg](#platformcostleg) | One row per leg, in its order. |
| `total` | number | The sum of every leg's `cost` whose `inTotal` is set, in dollars. |
| `totalKind` | string | What the total is: an estimate whenever any leg in it is one. |
| `frozenThrough` | string (64-bit integer) | The start of the earliest month the rollups have frozen across the organization's projects, Unix ms; 0 when nothing is frozen. |
| `floor` | string | The line on the fixed floor, which the environment carries. |

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "month": "2026-09",
  "projectCount": 2,
  "legs": [
    {
      "leg": "Session minutes",
      "quantity": 300,
      "unit": "session minutes",
      "quantityKind": "measured",
      "unitCost": 0.000428,
      "unitCostKind": "an assumption",
      "cost": 0.1284,
      "costKind": "an assumption",
      "inTotal": false,
      "note": "a second view of the floor; never added to the minute row the total carries (9.5)"
    },
    {
      "leg": "Engine data bytes sent",
      "quantity": 0.5,
      "unit": "GB",
      "quantityKind": "measured",
      "unitCost": 0.12,
      "unitCostKind": "a parameter",
      "cost": 0.06,
      "costKind": "an estimate",
      "inTotal": true,
      "note": "every engine byte taken as internet egress; some stays inside Google's network (9.3)"
    }
  ],
  "total": 0.06,
  "totalKind": "an estimate",
  "frozenThrough": "1788220800000",
  "floor": "the fixed floor is carried by the environment, not by this organization"
}
```

## Members

Who is in an organization and with which role, a change of role, and a removal, which ends their roles on the organization and its projects and signs them out of the console.

### `GET /v1/orgs/{orgId}/members`

Every member of the organization: the console user, their address, their role and when they joined.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `members` | array of [MemberView](#memberview) | One row per member, owners first and then by address. |

#### Example response

```json
{
  "members": [
    {
      "userId": "user_2737116ccba906a9d2229ddfdbf5b812",
      "email": "owner@example.com",
      "role": "org_owner",
      "relations": [
        "owner"
      ],
      "joinedAt": "1774000000000",
      "caller": true
    },
    {
      "userId": "user_9c41d0e7a3b25f86c1d4e09b7a63f215",
      "email": "dev@example.com",
      "role": "org_member",
      "relations": [
        "member"
      ],
      "joinedAt": "1774000600000"
    }
  ]
}
```

### `PATCH /v1/orgs/{orgId}/members/{userId}`

Change a member's organization role. Their project roles stay as they are.

Who may call it: An organization key carrying `members:manage`, or a console sign-in.

Permission: `members:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `role` | string | yes | `org_owner`, `org_admin`, `billing_admin` or `org_member`. |

#### Response body

The [MemberView](#memberview) object, written out under **Objects**.

#### Example request

```json
{
  "role": "org_admin"
}
```

#### Example response

```json
{
  "userId": "user_9c41d0e7a3b25f86c1d4e09b7a63f215",
  "email": "dev@example.com",
  "role": "org_admin",
  "relations": [
    "admin"
  ],
  "joinedAt": "1774000600000"
}
```

### `DELETE /v1/orgs/{orgId}/members/{userId}`

Remove a member: their relations on the organization and its projects end, and so do their console sign-ins.

Who may call it: An organization key carrying `members:manage`, or a console sign-in.

Permission: `members:manage`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [MemberView](#memberview) object, written out under **Objects**.

#### Example response

```json
{
  "userId": "user_9c41d0e7a3b25f86c1d4e09b7a63f215",
  "email": "dev@example.com",
  "role": "org_admin",
  "relations": [
    "admin"
  ],
  "joinedAt": "1774000600000"
}
```

## Billing accounts

An organization's billing accounts, the projects attached to each, a month's usage rolled up per billing account, and each billing account's invoices, their PDFs and its invoice webhooks. Platform staff issue an ended month at once and mark an invoice paid or void.

### `GET /v1/orgs/{orgId}/billing-accounts`

The organization's billing accounts the caller may view, the default first, each with the projects attached to it.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `billingAccounts` | array of [BillingAccountView](#billingaccountview) | The default first, then by creation. |

#### Example response

```json
{
  "billingAccounts": [
    {
      "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "name": "Default",
      "isDefault": true,
      "createdAt": "1774000000000",
      "projectIds": [
        "proj_k8m2p4x7q9z3v5w6t1yn"
      ]
    },
    {
      "billingAccountId": "bill_8wcd7g4kq2xm9pbz3tvn",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "name": "Agency",
      "isDefault": false,
      "legalName": "Agency Ltd",
      "billingEmail": "accounts@agency.example",
      "createdAt": "1774000600000"
    }
  ]
}
```

### `POST /v1/orgs/{orgId}/billing-accounts`

Create a billing account. It is not the default; attach projects to it one by one.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | 1-64 characters, not unique. |
| `legalName` | string | no | The billed party's legal name. |
| `billingEmail` | string | no | The address invoices are sent to. |
| `address` | string | no | The billed party's postal address, free text. |
| `taxId` | string | no | The billed party's tax identifier, free text. |

#### Response body

The [BillingAccountView](#billingaccountview) object, written out under **Objects**.

#### Example request

```json
{
  "name": "Agency",
  "legalName": "Agency Ltd",
  "billingEmail": "accounts@agency.example"
}
```

#### Example response

```json
{
  "billingAccountId": "bill_8wcd7g4kq2xm9pbz3tvn",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Agency",
  "isDefault": false,
  "legalName": "Agency Ltd",
  "billingEmail": "accounts@agency.example",
  "createdAt": "1774000600000"
}
```

### `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}`

One billing account, with the projects attached to it.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [BillingAccountView](#billingaccountview) object, written out under **Objects**.

#### Example response

```json
{
  "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Default",
  "isDefault": true,
  "createdAt": "1774000000000",
  "projectIds": [
    "proj_k8m2p4x7q9z3v5w6t1yn"
  ]
}
```

### `PATCH /v1/orgs/{orgId}/billing-accounts/{billingAccountId}`

Rename a billing account, set the billed party's details, or make it the organization's default.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no | 1-64 characters. |
| `isDefault` | boolean | no | `true` makes this billing account the organization's default and the old default an ordinary one; `false` is refused, because an organization always keeps a default: make another the default instead. |
| `legalName` | string | no | An empty string clears it. |
| `billingEmail` | string | no | An empty string clears it. |
| `address` | string | no | An empty string clears it. |
| `taxId` | string | no | An empty string clears it. |

#### Response body

The [BillingAccountView](#billingaccountview) object, written out under **Objects**.

#### Example request

```json
{
  "isDefault": true,
  "taxId": "GB123456789"
}
```

#### Example response

```json
{
  "billingAccountId": "bill_8wcd7g4kq2xm9pbz3tvn",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "name": "Agency",
  "isDefault": true,
  "legalName": "Agency Ltd",
  "billingEmail": "accounts@agency.example",
  "taxId": "GB123456789",
  "createdAt": "1774000600000"
}
```

### `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/usage`

A billing account's month roll-up: each attached project's owned meters for one UTC month, and their totals.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `month` | string | no | The UTC month to read, as `YYYY-MM`. Omit it for the current month. Anything else is refused `validation` with `details.field` `month`. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `billingAccountId` | string | The billing account rolled up. |
| `orgId` | string | Its organization. |
| `month` | string | The UTC month read, as YYYY-MM. |
| `lines` | array of [BillingUsageLine](#billingusageline) | One line per project and owned meter, by project and then in the meter order above; a project contributes only the days it was attached to this billing account. |
| `totals` | array of [BillingMeterTotal](#billingmetertotal) | One row per owned meter, in the same order. |
| `frozenThrough` | string (64-bit integer) | The start of the earliest month the rollups have frozen across the projects read, Unix ms; 0 when nothing is frozen. |

#### Example response

```json
{
  "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "month": "2026-09",
  "lines": [
    {
      "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
      "meter": "client_minutes",
      "leg": "Client minutes",
      "quantity": 1240.5,
      "unit": "client minutes"
    },
    {
      "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
      "meter": "session_minutes",
      "leg": "Session minutes",
      "quantity": 310,
      "unit": "session minutes"
    },
    {
      "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
      "meter": "relay_bytes",
      "leg": "Relay bytes for direct links",
      "quantity": 52428800,
      "unit": "bytes"
    },
    {
      "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
      "meter": "data_bytes_sent",
      "leg": "Engine data bytes sent",
      "quantity": 734003200,
      "unit": "bytes"
    }
  ],
  "totals": [
    {
      "meter": "client_minutes",
      "leg": "Client minutes",
      "quantity": 1240.5,
      "unit": "client minutes"
    },
    {
      "meter": "session_minutes",
      "leg": "Session minutes",
      "quantity": 310,
      "unit": "session minutes"
    },
    {
      "meter": "relay_bytes",
      "leg": "Relay bytes for direct links",
      "quantity": 52428800,
      "unit": "bytes"
    },
    {
      "meter": "data_bytes_sent",
      "leg": "Engine data bytes sent",
      "quantity": 734003200,
      "unit": "bytes"
    }
  ],
  "frozenThrough": "1788220800000"
}
```

### `POST /v1/projects/{projectId}/billing-account`

Attach a project to another billing account of its organization. Its usage rolls up there from the start of that UTC day.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `billingAccountId` | string | yes | A `bill_` id of the project's own organization. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project, as `proj_...`. |
| `billingAccountId` | string | The billing account it is attached to now. |
| `previousBillingAccountId` | string | The one it was attached to before; empty when it had none. |

#### Example request

```json
{
  "billingAccountId": "bill_8wcd7g4kq2xm9pbz3tvn"
}
```

#### Example response

```json
{
  "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
  "billingAccountId": "bill_8wcd7g4kq2xm9pbz3tvn",
  "previousBillingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k"
}
```

### `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices`

A billing account's invoices, newest month first: number, period, status, total and currency. Paged.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Query parameters

These travel in the query string. This route takes no body.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `cursor` | string | no | Where to carry on from: the `nextCursor` the page before this one answered with. It is opaque, and it belongs to this listing: one this service did not answer with, or one from another listing, is refused `validation` with `details.field` `cursor` rather than answering an empty page. |
| `limit` | number | no | How many invoices this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit` rather than shrunk. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `invoices` | array of [InvoiceView](#invoiceview) | Headers: `lines` is empty on each. |
| `nextCursor` | string | Empty when this page is the last. |

#### Example response

```json
{
  "invoices": [
    {
      "invoiceId": "invc_7g4kq2xm9pbz3tvn8wcd",
      "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "period": "2026-09",
      "periodStart": "1788220800000",
      "periodEnd": "1790812800000",
      "status": "paid",
      "number": "Q2XM9PBZ-0001",
      "rateCardVersion": 1,
      "currency": "USD",
      "accountName": "Default",
      "issuedAt": "1790992800000",
      "paidAt": "1790992800000",
      "statusReason": "zero_total",
      "pdfSha256": "<64 hex characters>",
      "frozenThrough": "1790812800000"
    }
  ]
}
```

### `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices/{invoiceId}`

One invoice with its line items and the rate card version it was rated on.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [InvoiceView](#invoiceview) object, written out under **Objects**.

#### Example response

```json
{
  "invoiceId": "invc_7g4kq2xm9pbz3tvn8wcd",
  "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "period": "2026-09",
  "periodStart": "1788220800000",
  "periodEnd": "1790812800000",
  "status": "paid",
  "number": "Q2XM9PBZ-0001",
  "rateCardVersion": 1,
  "currency": "USD",
  "lines": [
    {
      "lineNo": 1,
      "kind": "usage",
      "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
      "meter": "client_minutes",
      "leg": "Client minutes",
      "quantity": 1240.5,
      "unit": "client minutes"
    },
    {
      "lineNo": 2,
      "kind": "usage",
      "projectId": "proj_k8m2p4x7q9z3v5w6t1yn",
      "meter": "data_bytes_sent",
      "leg": "Engine data bytes sent",
      "quantity": 734003200,
      "unit": "bytes"
    },
    {
      "lineNo": 3,
      "kind": "fee",
      "meter": "fee_monthly",
      "leg": "Monthly fee (rate card parameter)",
      "quantity": 1,
      "unit": "month"
    }
  ],
  "accountName": "Default",
  "issuedAt": "1790992800000",
  "paidAt": "1790992800000",
  "statusReason": "zero_total",
  "pdfSha256": "<64 hex characters>",
  "frozenThrough": "1790812800000"
}
```

### `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices/{invoiceId}/pdf`

The invoice's archived PDF, as `application/pdf`.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

None. The service answers `204`.

### `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks`

Read where the billing account has its invoice events delivered, and the key id a delivery is signed with. The signing secret is never returned here.

Who may call it: An organization key carrying `billing:read`, or a console sign-in.

Permission: `billing:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `url` | string | The `https` URL; empty when none is set. |
| `secret` | string | Hex, returned by the `PUT` that derives it and always empty on the `GET`; the value a receiver recomputes the signature with. |
| `kid` | string | The name of the signing key version, as `X-Bitskiff-Signature-Kid` carries it. |
| `kinds` | array of string | The invoice kinds the endpoint subscribes to: `invoice.issued`, `invoice.paid` and `invoice.voided`, each opt-in; empty is none. |

#### Example response

```json
{
  "url": "https://example.com/hooks/billing",
  "kid": "a1b2c3d4",
  "kinds": [
    "invoice.issued",
    "invoice.paid",
    "invoice.voided"
  ]
}
```

### `PUT /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks`

Set or clear the billing account's delivery URL and choose its invoice kinds. This response is the one place the signing secret is ever shown.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | no | The `https` URL deliveries go to, checked against the webhook URL policy. |
| `kinds` | [StringListPatch](#stringlistpatch) | no | Absent leaves the subscription as it was; present replaces it, and present with no `values` clears it. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `url` | string | The `https` URL; empty when none is set. |
| `secret` | string | Hex, returned by the `PUT` that derives it and always empty on the `GET`; the value a receiver recomputes the signature with. |
| `kid` | string | The name of the signing key version, as `X-Bitskiff-Signature-Kid` carries it. |
| `kinds` | array of string | The invoice kinds the endpoint subscribes to: `invoice.issued`, `invoice.paid` and `invoice.voided`, each opt-in; empty is none. |

#### Example request

```json
{
  "url": "https://example.com/hooks/billing",
  "kinds": {
    "values": [
      "invoice.issued",
      "invoice.paid",
      "invoice.voided"
    ]
  }
}
```

#### Example response

```json
{
  "url": "https://example.com/hooks/billing",
  "secret": "<64 hex characters>",
  "kid": "a1b2c3d4",
  "kinds": [
    "invoice.issued",
    "invoice.paid",
    "invoice.voided"
  ]
}
```

### `POST /v1/platform/orgs/{orgId}/invoices`

Issue an organization's invoices for one ended month now, rather than on the monthly pass. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `month` | string | no | The UTC month, YYYY-MM, which must have ended; empty is the month before the current one. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `invoices` | array of [InvoiceView](#invoiceview) | Headers: `lines` is empty on each. |
| `nextCursor` | string | Empty when this page is the last. |

#### Example request

```json
{
  "month": "2026-09"
}
```

#### Example response

```json
{
  "invoices": [
    {
      "invoiceId": "invc_7g4kq2xm9pbz3tvn8wcd",
      "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "period": "2026-09",
      "periodStart": "1788220800000",
      "periodEnd": "1790812800000",
      "status": "paid",
      "number": "Q2XM9PBZ-0001",
      "rateCardVersion": 1,
      "currency": "USD",
      "accountName": "Default",
      "issuedAt": "1790992800000",
      "paidAt": "1790992800000",
      "statusReason": "zero_total",
      "pdfSha256": "<64 hex characters>",
      "frozenThrough": "1790812800000"
    }
  ]
}
```

### `POST /v1/platform/orgs/{orgId}/invoices/{invoiceId}/status`

Mark an issued invoice paid, or void an issued or paid one, with a reason. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string | yes | `paid` or `void`. |
| `reason` | string | yes | 1-200 characters. |

#### Response body

The [InvoiceView](#invoiceview) object, written out under **Objects**.

#### Example request

```json
{
  "status": "void",
  "reason": "issued for a walk-through"
}
```

#### Example response

```json
{
  "invoiceId": "invc_7g4kq2xm9pbz3tvn8wcd",
  "billingAccountId": "bill_q2xm9pbz3tvn8wcd7g4k",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "period": "2026-09",
  "periodStart": "1788220800000",
  "periodEnd": "1790812800000",
  "status": "void",
  "number": "Q2XM9PBZ-0001",
  "rateCardVersion": 1,
  "currency": "USD",
  "accountName": "Default",
  "issuedAt": "1790992800000",
  "paidAt": "1790992800000",
  "voidedAt": "1791000000000",
  "statusReason": "issued for a walk-through",
  "pdfSha256": "<64 hex characters>",
  "frozenThrough": "1790812800000"
}
```

## Invitations

Invite someone into an organization, read what is open, and revoke one. An invitation is accepted in the console, by the person who was invited. Platform staff invite someone to the platform the same way, with the plan the organization they create will carry.

### `GET /v1/orgs/{orgId}/invitations`

Every invitation the organization holds, newest first, each with the state the clock puts it in.

Who may call it: An organization key carrying `members:invite`, or a console sign-in.

Permission: `members:invite`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `invitations` | array of [InvitationView](#invitationview) | One invitation per row, newest first. |

#### Example response

```json
{
  "invitations": [
    {
      "invitationId": "inv_rdbbjmyewanr9aa600qw",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "email": "dev@example.com",
      "roles": [
        "org_member"
      ],
      "invitedBy": "user_2737116ccba906a9d2229ddfdbf5b812",
      "createdAt": "1774000000000",
      "expiresAt": "1774604800000",
      "state": "open"
    }
  ]
}
```

### `POST /v1/orgs/{orgId}/invitations`

Invite someone into the organization by email. A key may invite only at or below its own standing.

Who may call it: An organization key carrying `members:invite`, or a console sign-in.

Permission: `members:invite`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes | The address to invite. |
| `roles` | array of string | yes | The roles accepting the invitation writes. |
| `projectId` | string | no | Empty unless a role in `roles` is a project role. |

#### Response body

The [InvitationView](#invitationview) object, written out under **Objects**.

#### Example request

```json
{
  "email": "dev@example.com",
  "roles": [
    "org_member"
  ]
}
```

#### Example response

```json
{
  "invitationId": "inv_rdbbjmyewanr9aa600qw",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "email": "dev@example.com",
  "roles": [
    "org_member"
  ],
  "invitedBy": "user_2737116ccba906a9d2229ddfdbf5b812",
  "createdAt": "1774000000000",
  "expiresAt": "1774604800000",
  "state": "open"
}
```

### `DELETE /v1/orgs/{orgId}/invitations/{invitationId}`

Revoke an open invitation. The link it mailed stops working from the next request.

Who may call it: An organization key carrying `members:invite`, or a console sign-in.

Permission: `members:invite`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [InvitationView](#invitationview) object, written out under **Objects**.

#### Example response

```json
{
  "invitationId": "inv_rdbbjmyewanr9aa600qw",
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "email": "dev@example.com",
  "roles": [
    "org_member"
  ],
  "createdAt": "1774000000000",
  "revokedAt": "1774000500000",
  "state": "revoked"
}
```

### `POST /v1/invitations/accept`

Accept an invitation with the token from its mail, as the signed-in console user.

Who may call it: A console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | no | The token out of the invitation mail. It is good once. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization that was joined. |
| `orgName` | string | Its name. |
| `roles` | array of string | The relations the invitation carried, now held by the person who accepted it. |

#### Example request

```json
{
  "token": "q7Rk2mWv..."
}
```

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "orgName": "Northwind",
  "roles": [
    "org_member"
  ]
}
```

### `GET /v1/platform/invitations`

Every platform invitation, newest first, each with the state the clock puts it in. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `invitations` | array of [InvitationView](#invitationview) | One invitation per row, newest first. |

#### Example response

```json
{
  "invitations": [
    {
      "invitationId": "inv_a14xc28ymkn7tx9qz8pv",
      "email": "dev@example.com",
      "invitedBy": "user_2737116ccba906a9d2229ddfdbf5b812",
      "createdAt": "1774000000000",
      "expiresAt": "1776592000000",
      "state": "open",
      "kind": "platform",
      "plan": {
        "name": "experimental",
        "expiresAt": "1776592000000"
      }
    }
  ]
}
```

### `POST /v1/platform/invitations`

Invite a developer to the platform by email, with a plan. Accepting it creates their organization. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes | The address to invite to the platform. |
| `plan` | [PlanRequest](#planrequest) | yes | The plan the organization created on acceptance will carry. `plan.expiresAt` is required when the plan is `experimental`. |

#### Response body

The [InvitationView](#invitationview) object, written out under **Objects**.

#### Example request

```json
{
  "email": "dev@example.com",
  "plan": {
    "name": "experimental",
    "expiresAt": "1776592000000"
  }
}
```

#### Example response

```json
{
  "invitationId": "inv_a14xc28ymkn7tx9qz8pv",
  "email": "dev@example.com",
  "invitedBy": "user_2737116ccba906a9d2229ddfdbf5b812",
  "createdAt": "1774000000000",
  "expiresAt": "1776592000000",
  "state": "open",
  "kind": "platform",
  "plan": {
    "name": "experimental",
    "expiresAt": "1776592000000"
  }
}
```

### `DELETE /v1/platform/invitations/{invitationId}`

Revoke a pending platform invitation. The link it mailed stops working from the next request. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

The [InvitationView](#invitationview) object, written out under **Objects**.

#### Example response

```json
{
  "invitationId": "inv_a14xc28ymkn7tx9qz8pv",
  "email": "dev@example.com",
  "invitedBy": "user_2737116ccba906a9d2229ddfdbf5b812",
  "createdAt": "1774000000000",
  "expiresAt": "1776592000000",
  "revokedAt": "1774000500000",
  "state": "revoked",
  "kind": "platform",
  "plan": {
    "name": "experimental",
    "expiresAt": "1776592000000"
  }
}
```

## Webhooks

Where a project has its events delivered, and the same events read back.

### `GET /v1/project/events`

The project's event stream: the same objects the webhook delivers, kept for seven days or 10,000 entries and read forward from a cursor.

Who may call it: A secret key or organization key carrying `usage:read`, or a console sign-in.

Permission: `usage:read`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string, not in a body.

| Field | Type | Required | Description |
|---|---|---|---|
| `cursor` | string | no | Where to read from: the `nextCursor` of the page before this one. Without it the page starts at the oldest event still kept, which is seven days back. It is opaque, and it belongs to this listing and this project: one this service did not answer with, or one from another listing, is refused `validation` with `details.field` `cursor`. |
| `limit` | number | no | How many events this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit` rather than shrunk. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `events` | array of [ProjectEvent](#projectevent) | The events, oldest first, as far as this page goes. |
| `nextCursor` | string | Where the next page starts. Send it back as `cursor`. It is absent when this page is the last, so read the pages until it stops coming rather than until it is empty. |
| `trimmed` | boolean | True when the `cursor` asked for is older than the oldest event the stream still keeps (seven days or 10,000 events): the page starts at the oldest kept, and what fell between is read from the ledger route instead. |

#### Example query

```http
GET /v1/project/events?limit=2
```

#### Example response

```json
{
  "events": [
    {
      "id": "m4tq2ow7zkc3xbv5ernh6jyd2a",
      "type": "open.refused",
      "at": "1774915200000",
      "data": "{\"orgId\":\"org_2f9wvkpcd87svnzcgkxs\",\"keyId\":\"key_y090cqev9fn21nxp\",\"label\":\"lobby-north\",\"code\":\"quota_exceeded\",\"clears\":\"waiting\",\"limit\":\"monthlySpendCap\",\"scope\":\"project\",\"scopeId\":\"proj_7hq2m9xk4rz3tv8bn6cw\",\"value\":1200,\"ceiling\":1000,\"resetAt\":1775001600000,\"requestId\":\"3f1b6a52-9c4d-4f0a-8b21-6de0c2f4a917\",\"count\":1}"
    },
    {
      "id": "u2fq6nz3wk7mcx4tbr5dh2pyga",
      "type": "open.refused",
      "at": "1774915500000",
      "data": "{\"orgId\":\"org_2f9wvkpcd87svnzcgkxs\",\"keyId\":\"key_y090cqev9fn21nxp\",\"code\":\"concurrency_limit\",\"clears\":\"waiting\",\"limit\":\"concurrentSessions\",\"scope\":\"project\",\"scopeId\":\"proj_7hq2m9xk4rz3tv8bn6cw\",\"value\":5,\"ceiling\":5,\"retryAfterMs\":10000,\"requestId\":\"8c07d4e1-5b3a-4c96-9f2d-1a7e50b8c364\",\"count\":240}"
    }
  ],
  "nextCursor": "1774915500000:u2fq6nz3wk7mcx4tbr5dh2pyga"
}
```

### `GET /v1/project/webhooks`

Read where the project has its events delivered, and the key id a delivery is signed with. The signing secret is never returned here.

Who may call it: A secret key or organization key carrying `webhooks:manage`, or a console sign-in.

Permission: `webhooks:manage`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `url` | string | Where deliveries are posted. |
| `secret` | string | Hex, returned by the `PUT` that derives it and always empty on the `GET`; the value a receiver recomputes the MAC with. |
| `kid` | string | Which signing secret a delivery was signed with. It travels on the `X-Bitskiff-Signature-Kid` header, so a rotation is readable at the receiver. |
| `kinds` | array of string | The opt-in kinds the project subscribes to: `session.ended` and `client.gone`, the usage records, and `limits.raise_approved` and `limits.raise_denied`, the outcome of a pending raise. |

#### Example response

```json
{
  "url": "https://example.com/hooks/bitskiff",
  "kid": "a1b2c3d4"
}
```

### `PUT /v1/project/webhooks`

Set or clear the delivery URL. This response is the one place the signing secret is ever shown.

Who may call it: A secret key or organization key carrying `webhooks:manage`, or a console sign-in.

Permission: `webhooks:manage`, on a credential that carries permissions at all.

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | no | A new URL for deliveries. The empty string clears the webhook. |
| `kinds` | [StringListPatch](#stringlistpatch) | no | Absent leaves the subscription as it was; present replaces it, and present with no `values` clears it. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `url` | string | Where deliveries are posted. |
| `secret` | string | Hex, returned by the `PUT` that derives it and always empty on the `GET`; the value a receiver recomputes the MAC with. |
| `kid` | string | Which signing secret a delivery was signed with. It travels on the `X-Bitskiff-Signature-Kid` header, so a rotation is readable at the receiver. |
| `kinds` | array of string | The opt-in kinds the project subscribes to: `session.ended` and `client.gone`, the usage records, and `limits.raise_approved` and `limits.raise_denied`, the outcome of a pending raise. |

#### Example request

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

#### Example response

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

## SDK releases

Which SDK releases ran in the last 30 days: per project, rolled up to the organization, and across the platform for staff. And the sha256 of every SDK download of a release.

### `GET /v1/project/sdk-inventory`

Read the SDK releases the project ran in the last 30 days: each package and version, the protocol version it negotiated, when it was last seen and how many sessions it served.

Who may call it: A secret key or organization key carrying `project:read`, or a console sign-in.

Permission: `project:read`, on a credential that carries permissions at all.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `rows` | array of [SdkRow](#sdkrow) | One row per package, version and protocol version. |
| `projectId` | string | The project the credential named. |

#### Example response

```json
{
  "rows": [
    {
      "sdk": "ts-host/0.2.1",
      "protocol": 1,
      "lastSeen": "1774000000000",
      "sessions30d": "42",
      "package": "ts-host",
      "version": "0.2.1",
      "protocolStatus": "current"
    }
  ],
  "projectId": "proj_g1qr384an9asb4kdp10e"
}
```

### `GET /v1/orgs/{orgId}/sdk-releases`

Read the SDK releases the organization's projects ran in the last 30 days, rolled up over every project.

Who may call it: An organization key carrying `projects:read`, or a console sign-in.

Permission: `projects:read`, on a credential that carries permissions at all.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization in the path. |
| `rows` | array of [SdkRow](#sdkrow) | One row per package, version and protocol version. |

#### Example response

```json
{
  "orgId": "org_2f9wvkpcd87svnzcgkxs",
  "rows": [
    {
      "sdk": "ts-host/0.2.1",
      "protocol": 1,
      "lastSeen": "1774000000000",
      "sessions30d": "57",
      "package": "ts-host",
      "version": "0.2.1",
      "protocolStatus": "current"
    }
  ]
}
```

### `GET /v1/platform/sdk-releases`

Every SDK release any organization ran in the last 30 days, with the organizations that ran it. Platform staff only.

Who may call it: An organization key, or a console sign-in.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `releases` | array of [PlatformSdkRelease](#platformsdkrelease) | Ordered by package, then version, then protocol version. |

#### Example response

```json
{
  "releases": [
    {
      "release": {
        "sdk": "ts-host/0.2.1",
        "protocol": 1,
        "lastSeen": "1774000000000",
        "sessions30d": "120",
        "package": "ts-host",
        "version": "0.2.1",
        "protocolStatus": "current"
      },
      "orgIds": [
        "org_2f9wvkpcd87svnzcgkxs"
      ],
      "orgs": [
        {
          "orgId": "org_2f9wvkpcd87svnzcgkxs"
        }
      ]
    }
  ]
}
```

### `GET /v1/releases/{version}/digests`

Every SDK download of one release with the sha256 of its bytes, so a download from the docs site can be checked against the service.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

An id in this path that the caller cannot reach is refused `not_found`; see [Errors](#errors).

#### Request body

None. This route reads what it needs from the path.

#### Response body

| Field | Type | Description |
|---|---|---|
| `version` | string | The release, `X.Y.Z`. |
| `digests` | array of [ReleaseDigest](#releasedigest) | One per SDK tarball, ordered by package. |
| `recordedAt` | string (64-bit integer) | Unix ms the cut recorded them. |

#### Example response

```json
{
  "version": "0.2.2",
  "digests": [
    {
      "package": "@bitskiff/client",
      "file": "0.2.2/bitskiff-client.tgz",
      "sha256": "3f1c9a5e0b7d24c8e6f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7",
      "bytes": "48213"
    },
    {
      "package": "@bitskiff/host",
      "file": "0.2.2/bitskiff-host.tgz",
      "sha256": "9a8b7c6d5e4f30211f2e3d4c5b6a79880a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "bytes": "102944"
    }
  ],
  "recordedAt": "1774000000000"
}
```

## Audit

Every key, limit and webhook change, with who made it.

### `GET /v1/project/audit`

The project's audit log: one entry per key, limit and webhook change, with who made it. Filtered and paged.

Who may call it: An organization key carrying `audit:read`, or a console sign-in.

Permission: `audit:read`, on a credential that carries permissions at all.

#### Query parameters

These travel in the query string, not in a body.

| Field | Type | Required | Description |
|---|---|---|---|
| `action` | string | no | Keep only the rows with this action, such as `key.created`. Omit it for every action. |
| `actor` | string | no | Keep only the rows this actor wrote. Omit it for every actor. |
| `since` | string (64-bit integer) | no | Ms since epoch, inclusive. |
| `until` | string (64-bit integer) | no | Ms since epoch, exclusive. |
| `cursor` | string | no | Where to carry on from: the `nextCursor` the page before this one answered with. It is opaque, and it belongs to this listing and this project: one this service did not answer with, or one from another listing, is refused `validation` with `details.field` `cursor`. |
| `limit` | number | no | How many rows this page may answer with, 1 to 200. Omit it for 50. Every paged route takes the same bound, and a value outside it is refused `validation` with `details.field` `limit` rather than shrunk. |

#### Response body

| Field | Type | Description |
|---|---|---|
| `events` | array of [AuditEvent](#auditevent) | One audited change per row. |
| `nextCursor` | string | Where the next page starts. Send it back as `cursor`. It is absent when this page is the last, so read the pages until it stops coming rather than until it is empty. |

#### Example query

```http
GET /v1/project/audit?action=key.created&limit=20
```

#### Example response

```json
{
  "events": [
    {
      "id": "evt_f2k9t2tc3a2vvtym54as",
      "at": "1774000000000",
      "orgId": "org_2f9wvkpcd87svnzcgkxs",
      "projectId": "proj_g1qr384an9asb4kdp10e",
      "actor": {
        "type": "user",
        "id": "user_2737116ccba906a9d2229ddfdbf5b812"
      },
      "action": "key.created",
      "target": {
        "type": "key",
        "id": "key_hn76yrbsbdghwpdv"
      },
      "after": {
        "label": "backend"
      },
      "requestId": "dcea9d32-21c0-4aac-8040-d9f90646ecc3"
    }
  ]
}
```

## Service

What this endpoint is, and what it speaks. Neither needs a credential.

### `GET /v1/whoami`

What this endpoint is: which region answers here, which environment it belongs to, and the address a session's live connection goes to.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `engineUrl` | string | Where this region's transport connects, and the same value a `Ticket` carries as `endpoint`. Hand it to the SDK as it is: the transport settles the scheme and the path it needs. It is not an address to open a socket on yourself. |
| `region` | string | The region that answered this call. |
| `env` | string | Which environment this endpoint belongs to: `local`, `dev` or `prod`. |
| `apiId` | string | The sender identity the service itself signs with in this region, `a_<region>`: the same value a ticket carries, offered here for support. |

#### Example response

```json
{
  "engineUrl": "https://rtc.example.com",
  "region": "us-central1",
  "env": "dev",
  "apiId": "a_us-central1"
}
```

### `GET /v1/protocol`

Which protocol versions this endpoint speaks, and when each one stops being accepted.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `versions` | array of [ProtocolVersion](#protocolversion) | One row per protocol version the service still speaks. |

#### Example response

```json
{
  "versions": [
    {
      "version": 1,
      "status": "current"
    }
  ]
}
```

## Benchmark

What the benchmark page measures a connection with. It opens no session, holds no credential and belongs to no project, and one address may start one run a minute.

### `POST /v1/bench/tickets`

Start a benchmark run: one throwaway engine room and the two credentials a page measures its own connection with.

Who may call it: Anyone, with no credential.

Permission: none. The credential class above is the whole of what this route asks for.

#### Request body

None.

#### Response body

| Field | Type | Description |
|---|---|---|
| `room` | string | The engine room this run is measured in. It is made for this answer, holds two participants at most, and finishes once it empties. |
| `signalingUrl` | string | Where both ends dial the engine, the same address `GET /v1/whoami` answers with. |
| `sender` | [BenchParticipant](#benchparticipant) | The end that sends. Both ends are the same browser: this one publishes the run's frames. |
| `receiver` | [BenchParticipant](#benchparticipant) | The end that receives, and where delivery is measured. |
| `expiresIn` | number | Seconds both credentials are good for. It is the whole of how long one run may last, and there is no way to extend it: a longer run is a new one. |

#### Example response

```json
{
  "room": "bench-4f1c9a2b7e30",
  "signalingUrl": "wss://rtc.example.com",
  "sender": {
    "ticket": "eyJhbGciOi..."
  },
  "receiver": {
    "ticket": "eyJhbGciOi..."
  },
  "expiresIn": 60
}
```

## The join code

A session answers `JoinCodeMaterial`, and turning that into the 15 characters a client scans is the one thing an SDK does locally. Over HTTP you do it yourself.

The code is `<version><region><sessionCode><rotating>`, all of it over `A-Za-z0-9` and case-sensitive: the version digit, `1`; the material's `regionHint` letter; the material's 7-character `sessionCode`; and a 6-character rotating part you compute. Show it exactly as built, never upper- or lower-cased. Any grouping you print it in is cosmetic, and a person may type it back with spaces or dashes.

The rotating part is RFC 4226 HOTP over a time counter, rendered base 62:

1. `counter = floor(unixMillis / 1000 / step)`, where `step` is the session's `joinCodeRotation`: `30s` by default, `15s` to `2m`, and the counter uses its whole seconds.
2. `mac = HMAC-SHA256(secret, counter as 8 big-endian bytes)`, where `secret` is the material's `secret` decoded from base64 (32 bytes).
3. Dynamic truncation, extended to five bytes: `offset = mac[31] & 0x0f`, then take the five bytes at `offset` as a big-endian number and keep its low 35 bits.
4. Render that number base 62 over the digits `0-9A-Za-z` (`A` is 10, `a` is 36), and left-pad it with `0` to 6 characters.

The service accepts the counter for now and one step either side, so a code a client sees stays accepted for at least one full step and at most two: 30 to 60 seconds at the default. Recompute it every step while the session is open.

Worked, with `secret` `c2VjcmV0LWtleS1mb3ItdGhlLXdvcmtlZC1saW5lISE=`, `sessionCode` `7F3a9Ce`, `regionHint` `C` and Unix ms `1774000000000`: the counter is `59133333`, the rotating part is `Qlexea`, and the code is `1C7F3a9CeQlexea`.

Where a client goes is the material's `joinUrl` with the code after the `#`.

## Operator routes

These belong to the service running itself rather than to an application. They are listed here because they answer on the same base URL as everything above, so this page is the whole of what the service answers. Nothing an SDK does calls one.

| Method and path | Who may call it | What it does |
|---|---|---|
| `GET /v1/project/deadlines/parked` | An organization key carrying `project:read`, or a console sign-in. | The deadlines a sweep parked after they kept erroring, so what is stuck can be seen. Each entry carries the `kind` and `member` the unpark route takes. |
| `POST /v1/project/deadlines/{kind}/{member}/unpark` | An organization key carrying `project:manage`, or a console sign-in. | Put a parked deadline back in the due set, so the next sweep takes it again. `{kind}` is one of `reconnect`, `background`, `host_resume`, `idle`, `max_time`, `snapshot`, `rotation` -- the scheduler's own deadline kinds, and not the per-client kinds an SDK sees. `{member}` is what that kind is keyed by: a session id for the session-scoped kinds, `<sessionId>:<clientId>` for `reconnect` and `background`, and a key id for `rotation`, a key rotation whose retirement was already overdue when the retire sweep first read it. An unparked `rotation` is due at the moment of the unpark, not at its old time, and retires the old key on the next sweep. Both come from `entry.kind` and `entry.member` of the parked list, so nothing has to be composed by hand. |
| `PUT /v1/releases/{version}/digests` | An organization key, or a console sign-in. | Record the sha256 of every SDK download of one release, once. The release cut calls it. Platform staff only. |
| `POST /v1/platform/notices` | An organization key, or a console sign-in. | Mail a release notice or a planned interruption of the engine node to every owner of every live organization, once per release or window. The release tool calls it. Platform staff only. |

## Error codes

Every value `code` takes. A row with no status is raised by an SDK without a round trip, and a row whose status reads *not returned* is part of the vocabulary that no route answers with today: nothing to branch on.

| Code | HTTP | Retryable | Clears | Meaning |
|---|---|---|---|---|
| `validation` | 400 | no | `never` | The request or the session config is malformed; `details.field` names the field, `details.header` the header. |
| `quota_above_ceiling` | 400 | no | `never` | A limit asked for on a key or a token is less restrictive than the level above it, or a pending raise approved while its value is still above the organization's plan (`details.scope` `org`, `details.ceiling` the plan's value); `details.scope` says which level bound it. |
| `e2ee_required` | 400 | no | `never` | The session requires end-to-end encryption and this join carries nothing to do it with. |
| `invalid_key` | 401 | no | `never` | The key is not usable, and `details.reason` says which of the five: `absent` (no `Authorization: Bearer` at all, or no `X-Bitskiff-Key` on a join route), `malformed` (a string carrying none of the credential prefixes), `unknown` (well-formed and this deployment holds no such key), `revoked`, `expired`. |
| `invalid_token` | 401 | no | `never` | The token is not usable; `details.reason` is `expired`, `revoked` or `unknown`, and the answer is a fresh token from your own backend. |
| `handoff_invalid` | 401 | no | `never` | The handoff ticket presented to `POST /v1/handoff` cannot be redeemed, or a resume names a place whose handoff it can no longer finish; `details.reason` is `expired`, `spent`, `unknown`, `superseded`, `cancelled` or `lapsed`, and the app shows one line then its scanner: a scan is the way in. |
| `origin_not_allowed` | 403 | no | `never` | A publishable key without an allowed `Origin`. |
| `restricted_mode` | 403 | no | `never` | A publishable-key open on a restricted project. |
| `plan_expired` | 403 | no | `never` | The organization's plan passed its expiry, so opening a session and joining one are refused until staff renew it; sessions already live run to their own end, and reads, keys and sign-in keep working. |
| `secret_key_in_browser` | 403 | no | `never` | A secret or organization key presented with any `Origin` header; audited. |
| `forbidden` | 403 | no | `never` | The credential may not call this route, does not own this session, or lacks a permission it needs; `details.reason` says which. |
| `wrong_client` | 403 | no | `never` | This code is for a different client: the client key is not for this session, because the session is another project's or its host is one the key does not serve; `details.reason` is `other_project` or `other_host` and names nothing else; on `POST /v1/handoff` it is also `other_app`, a key of this project that is not the registered app's. |
| `not_found` | 404 | no | `never` | Nothing the caller can see holds the id, slug or path the request named; a route this service does not carry answers this way, with `details.path`. |
| `session_not_found` | 404 | no | `never` | No session with this id is live for this caller: none is, or the host or resume token presented is not a handle of it; it never says a session is live. |
| `invalid_code` | 404 | no | `never` | The join code did not decode, or its rotating part did not verify; counted against the bad-code limit. |
| `clients_full` | 409 | no | `waiting` | The session is at `maxClients`. |
| `session_ended` | 409 | no | `never` | The session has already ended; a fresh `open` is the way on. |
| `host_released` | 409 | no | `waiting` | The host has let its engine connection go and serves its clients over another transport, so nothing reaches it to tell it a client waits; the join may be tried again once the host rejoins. |
| `key_rotating` | 409 | no | `waiting` | The key is inside its rotation overlap and cannot take this change. |
| `join_url_missing` | 409 | no | `never` | The project has no `joinUrl` to point a client at; `details.field` is `joinUrl`. |
| `replaced` | 409 | no | `never` | A resume after the engine's `duplicate_identity` close, and a later ticket was minted for this identity since: a second holder of the same token took its place, so this handle ends `replaced`. |
| `handed_off` | 409 | no | `never` | A resume after the engine's `duplicate_identity` close, and the place's handoff committed to the successor this page named: the place lives on under it and this handle's part is over, so it ends `handedOff`. |
| `handoff_pending` | 409 | yes | `waiting` | A resume after the engine's `duplicate_identity` close, and the place's successor has redeemed and not yet committed; ask again after `retryAfterMs`, which is not a budget, while the service reads the engine. |
| `subject_mismatch` | 403 | no | `never` | On `POST /v1/handoff`: the token beside the registered app's key names a subject other than the place's, so the app cannot take this place; the handoff fails at stage `refused` and the page keeps the place. |
| `key_mismatch` | 403 | no | `never` | On `POST /v1/handoff` into a place that encrypts: the token beside the registered app's key has no `cnf`, the redeem carries no `key_thumbprint`, or the `cnf` names another key than the one the app will present, so the app cannot take this place; refused before anything moves, the handoff fails at stage `refused` and the page keeps the place. |
| `idempotency_in_flight` | 409 | yes | `waiting` | An open, or a handoff redeem, with this `Idempotency-Key` is still running; retry after `retryAfterMs` for its answer. |
| `seat_spent` | 410 | no | `never` | This client's budget is spent; rejoin with a code. |
| `host_resume_expired` | 410 | no | `never` | The host's resume window is spent; open a fresh session. |
| `protocol_unsupported` | 426 | no | `never` | The service does not speak the protocol version this caller asked for; `details.supported` lists the ones it does. |
| `rate_limited` | 429 | yes | `waiting` | Over a rate limit, per key, token, IP or session; `retryAfterMs` set. |
| `concurrency_limit` | 429 | yes | `waiting` | A `concurrentSessions` or `concurrentClients` ceiling is full; `details.scope` says which level bound the call, `key` or `project`, and `retryAfterMs` is 10000 ms on every path that sends this code. |
| `quota_exceeded` | 429 | no | `waiting` | A usage cap or the spend cap is spent; `retryAfterMs` is the time to reset. |
| `turn_quota_exceeded` | 429 | no | `waiting` | The project's relay byte cap (`turn_bytes_per_month`) is spent: the direct path's relay credential route mints no credential, and the host stays on the engine for that client; `retryAfterMs` is the time to the month's reset. |
| `internal` | 500 | no | `waiting` | Something inside the service failed; `requestId` is the handle support asks for. |
| `relay_not_configured` | 503 | no | `never` | This environment runs no Bitskiff relay, or its relay lists no `turn:` URL, so the direct path's relay credential route has nothing to mint; the host offers with its own STUN or stays on the engine. |
| `unavailable` | 503 | yes | `waiting` | The service or a dependency is down; `retryAfterMs` set. |
| `overloaded` | 503 | yes | `waiting` | The platform is at its admission ceiling; the open or join was refused and nothing live was touched; `retryAfterMs` set. |
| `network` | - | yes | `waiting` | The request never reached the service. |
| `timeout` | - | yes | `waiting` | The request or the handshake did not finish inside its budget. |
| `protocol` | - | no | `never` | A response or an envelope did not match the pinned protocol. |
| `engine` | - | no | `waiting` | The transport or the engine SDK failed; `cause` carries its error, never rethrown. |
| `disposed` | - | no | `never` | The handle was disposed before the call could finish. |
| `listener_error` | - | no | `never` | A listener the app bound with `on` threw; `details.event` names the event and `cause` carries the throw. |
| `sdk_internal` | - | no | `never` | Something inside this SDK failed and no request was refused; `cause` carries the error, never rethrown, and the SDK's log line at `error` names the step. |
| `e2ee_unsupported` | - | no | `never` | This client cannot do the end-to-end encryption the project requires; answered at `inspect`. |

## Objects

The objects the bodies above are made of, each written once.

### AuditActor

Who made a change: a console user, a key, or the platform itself.

| Field | Type | Description |
|---|---|---|
| `type` | string | "user" \| "admin_key" \| "secret_key" \| "token" \| "system". |
| `id` | string | The actor's id: a console user, a key, or the platform itself. |

### AuditEvent

One audited change: what was done, to what, by whom and when.

| Field | Type | Description |
|---|---|---|
| `id` | string | The row's own id. |
| `at` | string (64-bit integer) | When it happened, Unix ms. |
| `orgId` | string | The organization it happened in. |
| `projectId` | string | The project it happened in, when it was a project's change. |
| `actor` | [AuditActor](#auditactor) | Who did it. |
| `action` | string | What was done, such as `key.created`. |
| `target` | [AuditTarget](#audittarget) | What it was done to. |
| `before` | object, string keys to string | The fields that changed, as they were. |
| `after` | object, string keys to string | The same fields, as they are now. |
| `requestId` | string | The request that made the change: the handle support traces it by. |
| `ip` | string | The address the request came from. |

### AuditTarget

What a change was made to, by its kind and its id.

| Field | Type | Description |
|---|---|---|
| `type` | string | "key" \| "project" \| "session". |
| `id` | string | The id of what was changed. |

### BenchParticipant

One end of a benchmark run.

| Field | Type | Description |
|---|---|---|
| `ticket` | string | What this end dials the engine with. It may send and receive data, may publish no track, and is good for this room only. |

### BillingAccountView

A billing account: the payer an organization's invoices are addressed to.

| Field | Type | Description |
|---|---|---|
| `billingAccountId` | string | The `bill_` id. |
| `orgId` | string | The organization it belongs to, as `org_...`. |
| `name` | string | The organization's own word for it, 1-64 characters. |
| `isDefault` | boolean | True on the organization's default billing account, the one the monthly fee lands on; exactly one billing account of an organization is the default. |
| `legalName` | string | The billed party's legal name, as an invoice prints it; absent until entered. |
| `billingEmail` | string | The address invoices are sent to; absent until entered. |
| `address` | string | The billed party's postal address, free text; absent until entered. |
| `taxId` | string | The billed party's tax identifier, free text; absent until entered. |
| `createdAt` | string (64-bit integer) | Unix ms it was created. |
| `projectIds` | array of string | The `proj_` ids of the projects attached to it now. |

### BillingMeterTotal

One owned meter summed over every project of the roll-up.

| Field | Type | Description |
|---|---|---|
| `meter` | string | As `BillingUsageLine.meter`. |
| `leg` | string | As `BillingUsageLine.leg`. |
| `quantity` | number | The sum of that meter's lines. |
| `unit` | string | As `BillingUsageLine.unit`. |

### BillingUsageLine

One project's owned meter over the month: the line an invoice will carry.

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project, as `proj_...`. |
| `meter` | string | The meter: `client_minutes`, `session_minutes`, `relay_bytes` or `data_bytes_sent`, the four `billable` legs. |
| `leg` | string | The leg the meter is, as that table names it. |
| `quantity` | number | The month's quantity over the days the project was attached here. |
| `unit` | string | `client minutes`, `session minutes` or `bytes`. |

### CapacityLimit

One cap that is not `ok`.

| Field | Type | Description |
|---|---|---|
| `limit` | string | The parameter spelling `details.limit` carries (`clientMinutesPerDay`). |
| `scope` | string | What the cap is counted over: `project`, `key`, `subject`, `ip`, `session`, `client`, `org` or `address`. It is the same word a refusal carries in `details.scope`. |
| `scopeId` | string | The same id a refusal and a `limit.*` webhook carry. |
| `status` | string | One of `CAPACITY_STATUS_OK`, `CAPACITY_STATUS_WARNING`, `CAPACITY_STATUS_EXCEEDED`. |
| `value` | string (64-bit integer) | What the period has spent, in the row's own unit. |
| `ceiling` | string (64-bit integer) | The cap it is spending against. |
| `resetAt` | string (64-bit integer) | Unix ms when the period rolls; 0 for a cap with no period. |

### CapacityNotice

What a live session learns about its project's caps (the SDK's `CapacityNotice`).

| Field | Type | Description |
|---|---|---|
| `status` | string | The worst row's; OK over an empty list. One of `CAPACITY_STATUS_OK`, `CAPACITY_STATUS_WARNING`, `CAPACITY_STATUS_EXCEEDED`. |
| `limits` | array of [CapacityLimit](#capacitylimit) | One row per cap that is not OK; more than one stands at once. |
| `at` | string (64-bit integer) | Unix ms on the service's own clock: when this standing was read. |

### ChannelRow

One row of a session's channel table.

| Field | Type | Description |
|---|---|---|
| `channelId` | number | Allocated from 1 upward in declaration order, unique for the life of the session and never reused inside it. |
| `name` | string | Non-empty, at most 64 UTF-8 bytes; checked at declaration. |
| `delivery` | string | One of `DELIVERY_RELIABLE`, `DELIVERY_LOSSY`. |
| `ordered` | boolean | Handed over in send order; true on reliable, false on lossy by default. |
| `lifetimeMs` | number | 0 = unset. |
| `maxHz` | number | The most messages a second one sender sends on this channel. |
| `maxBytes` | number | The largest one message on this channel, sent or accepted. |
| `latestOnly` | boolean | Lossy only: drop a received packet behind the latest delivered. |
| `linkMaxHz` | number | The same row's rate on a direct link: the service fills it at the clamp from the typed `maxHz`, capped by the project's `linkMessagesPerSecond` row, so it is never below `maxHz` and equals it until that row is raised. |

### ClientEnvironment

What a client says about the program it runs in: stated by the client SDK, never by the developer, on every Hello and on the join, resume and redeem requests, so a join, a resume and a handoff's first Hello each restate it.

| Field | Type | Description |
|---|---|---|
| `platform` | string | The os-family registry: "android" \| "ios" \| "windows" \| "macos" \| "linux" \| "other". |
| `runtime` | string | The client-runtime registry: "browser" \| "webview" \| "native". |
| `sdkName` | string | The client SDK's package name, such as "ts-client": the first half of Hello.sdk. |
| `sdkVersion` | string | The client SDK's semver, such as "0.2.3": the second half of Hello.sdk, split so a roster can show the version alone. |
| `transport` | string | The transport this Hello arrived on: "server" (the session connection), "peer", or a declared transport's name. |

### ClientUsage

One client's usage, attributed on the client's own key and subject: what a developer that bills the people who took part bills on. The same fields and the same `id` on the ledger and the `client.gone` webhook.

| Field | Type | Description |
|---|---|---|
| `id` | string | The record's one identifier: the event id of the `client.gone` that closed it, or, for a client closed by its session's end, that `session.ended` event id joined with the client id. Dedupe on it. |
| `sessionId` | string | The session the client was in. |
| `clientId` | string | The client's id in that session. |
| `keyId` | string | The client key the client joined with. Empty for the sample client page. |
| `sessionKeyId` | string | The key that opened the session, the session record's `keyId`. |
| `subject` | string | The subject of the client's own join token. Empty for an anonymous client. |
| `label` | string | The host's label for the session. |
| `joinedAt` | string (64-bit integer) | When the client joined, Unix ms. |
| `goneAt` | string (64-bit integer) | When the client's place was released, Unix ms. |
| `goneReason` | string | Why the client is gone, a `gone` reason. |
| `bundle` | string | The bundle the client held when it left. Price a promoted client on `msByBundle`, not on this. |
| `seatMs` | string (64-bit integer) | The client's time in milliseconds: connected, away or disconnected. Joining is not counted. |
| `msByBundle` | object, string keys to string (64-bit integer) | Time under each bundle the client held, joining included, so it sums to its whole time. |
| `resumed` | boolean | Whether the client reconnected into its place inside its budget. |
| `bytesReported` | string (64-bit integer) | The client's bytes through the engine on every channel and direction, as the host reported them. A display figure, never a price. |
| `writtenAt` | string (64-bit integer) | When the ledger last wrote this record, Unix ms. Absent on the webhook. |
| `writerRevision` | number | Which revision of the ledger's writer wrote the record. Absent on the webhook. |
| `subjectUnknown` | boolean | True when the client's client record could not be read when it left, so `subject` and `keyId` are empty because they are unknown, not because the client joined without them. |
| `messagesReported` | string (64-bit integer) | The client's messages on every channel and direction, as the host reported them, beside `bytesReported`. A display figure: peer and declared transports are logged, never here. |
| `dataMessagesUp` | string (64-bit integer) | Messages the service received from the client on the server transport, as the service counted them: the message figure to bill a subject on. Counted as each of the client's connections closes. Messages on peer or a declared transport never pass through the service and are not counted. |
| `dataMessagesDown` | string (64-bit integer) | Messages the service sent to the client on the server transport, as the service counted them. Not counted on peer or a declared transport. |
| `dataBytesUp` | string (64-bit integer) | Payload bytes of `dataMessagesUp`, as the service measured them: the byte figure to bill a subject on. Not counted on peer or a declared transport. |
| `dataBytesDown` | string (64-bit integer) | Payload bytes of `dataMessagesDown`, as the service measured them. Not counted on peer or a declared transport. On the webhook, a connection still closing when the client left is not in these four yet; the ledger record has it. |

### ControlCount

One kind of control frame and how many of it arrived.

| Field | Type | Description |
|---|---|---|
| `kind` | string | The frame's kind, such as `hello`, `presence`, `ping`, `heartbeat` or `transport_setup`. |
| `count` | string (64-bit integer) | How many frames of this kind arrived. |

### FixedCodeView

One fixed code of the project: a join code that does not rotate, for an NFC tag or a printed QR.

| Field | Type | Description |
|---|---|---|
| `id` | string | The opaque id a host names at open; it admits nobody. |
| `label` | string | The developer's own text for the NFC tag or sticker that carries it. |
| `code` | string | The 15-character code, filled on the create and replace answers only and empty on every other: the service keeps only its SHA-256, so a lost code is replaced. |
| `sessionId` | string | The open session holding the claim; empty when none does. |
| `createdAtMs` | string (64-bit integer) | Unix ms. |
| `replacedAtMs` | string (64-bit integer) | Unix ms of the last replace; 0 for never replaced. |

### HandoffHint

The page's transport as the page stated it on the mint: a fact about a class, never an instance.

| Field | Type | Description |
|---|---|---|
| `transport` | string | The class: "peer", "relay", "server" or a declared transport's name. |
| `grade` | string | Its last grade, "good", "fair" or "poor"; empty when none was read. |
| `rttMs` | number | Its last round trip time reading, in ms; 0 when none was read. |
| `noDirectPath` | boolean | The page found no direct path from where it stood. |

### HandoffSuccessor

The successor of a handoff under way, as a host reads it.

| Field | Type | Description |
|---|---|---|
| `kind` | string | The successor's kind of process: "page" \| "app". |
| `phase` | string | One of `HANDOFF_PHASE_PENDING`, `HANDOFF_PHASE_REDEEMED`, `HANDOFF_PHASE_DONE`, `HANDOFF_PHASE_FAILED`. |
| `deadlineMs` | string (64-bit integer) | Unix ms on the service's clock the handoff window ends. |
| `hint` | [HandoffHint](#handoffhint) | The page's transport as it stated it on the mint; absent when it stated none. |

### InvitationView

One invitation into an organization: who was invited, what accepting it writes, and where it stands.

| Field | Type | Description |
|---|---|---|
| `invitationId` | string | The invitation's id. |
| `orgId` | string | The organization the invitation is into. |
| `email` | string | The address that was invited. |
| `roles` | array of string | The roles accepting it writes. |
| `projectId` | string | Empty unless a role is a project role. |
| `invitedBy` | string | The `user_` id of the console user who sent it. |
| `createdAt` | string (64-bit integer) | Unix ms. |
| `expiresAt` | string (64-bit integer) | Unix ms. |
| `acceptedAt` | string (64-bit integer) | Unix ms; 0 while it is not accepted. |
| `revokedAt` | string (64-bit integer) | Unix ms; 0 while it is not revoked. |
| `state` | string | "open", "accepted", "revoked" or "expired", resolved against the clock at read time so a caller does not have to hold one to know which row is still actionable. |
| `kind` | string | "organization" (into an existing organization) or "platform" (creates a new one on acceptance). |
| `plan` | [PlanRequest](#planrequest) | The plan the organization created on acceptance will carry. Set only on a platform invitation. |

### InvoiceLineView

One line item of an invoice.

| Field | Type | Description |
|---|---|---|
| `lineNo` | number | 1-based, in the order the invoice prints its lines. |
| `kind` | string | `usage` (one project and one owned meter over the month), `fee` (the monthly fee parameter, on the organization's default billing account), `credit` or `adjustment`. |
| `projectId` | string | The project a usage line is for, as `proj_...`; empty on the fee line. |
| `meter` | string | `client_minutes`, `session_minutes`, `relay_bytes` or `data_bytes_sent` on a usage line; `fee_monthly` on the fee line. |
| `leg` | string | What the line is, as and 11 name it. |
| `quantity` | number | The month's quantity over the days the project was attached here. |
| `unit` | string | `client minutes`, `session minutes`, `bytes` or `month`. |
| `unitPriceMicros` | string (64-bit integer) | The line's rate card parameter, the price per unit in millionths of `currency`; every owned meter and the monthly fee has its own. |
| `amountMicros` | string (64-bit integer) | The line's amount, in millionths of `currency`, rounded to the cent. |
| `reason` | string | Why a credit or an adjustment line exists; empty otherwise. |

### InvoiceView

An invoice: one per billing account per calendar month (UTC), numbered in the billing account's own sequence once issued and never edited after.

| Field | Type | Description |
|---|---|---|
| `invoiceId` | string | The `invc_` id. |
| `billingAccountId` | string | The billing account it is addressed to. |
| `orgId` | string | The billed organization. |
| `period` | string | The UTC month it covers, as YYYY-MM. |
| `periodStart` | string (64-bit integer) | Unix ms the month starts, inclusive. |
| `periodEnd` | string (64-bit integer) | Unix ms the next month starts, exclusive. |
| `status` | string | `issued`, `paid` or `void`; a zero total is issued already `paid`, and a void keeps its number. |
| `number` | string | The printed number: eight characters of the billing account's id, then its own sequence, as `Q2XM9PBZ-0001`; the numbers of one billing account have no gap. |
| `rateCardVersion` | number | The version of the rate card it was rated on. |
| `currency` | string | ISO 4217, as `USD`. |
| `subtotalMicros` | string (64-bit integer) | The sum of the lines, in millionths of `currency`. |
| `totalMicros` | string (64-bit integer) | What is owed, in millionths of `currency`. |
| `lines` | array of [InvoiceLineView](#invoicelineview) | Every line item on a read of one invoice; empty on a list, which carries headers only. |
| `accountName` | string | The billing account's name when it was issued. |
| `legalName` | string | The billed party's legal name when it was issued; absent when none was entered. |
| `billingEmail` | string | The address invoices were sent to when it was issued; absent when none was entered. |
| `address` | string | The billed party's postal address when it was issued; absent when none was entered. |
| `taxId` | string | The billed party's tax identifier when it was issued; absent when none was entered. |
| `issuedAt` | string (64-bit integer) | Unix ms it was issued. |
| `paidAt` | string (64-bit integer) | Unix ms it was marked paid; 0 while it is not. |
| `voidedAt` | string (64-bit integer) | Unix ms it was voided; 0 while it is not. |
| `statusReason` | string | Why it is paid or void: `zero_total` on an invoice issued already paid, else the reason staff gave; empty while it is `issued`. |
| `pdfSha256` | string | The hex SHA-256 of the archived PDF. |
| `frozenThrough` | string (64-bit integer) | How far the usage rollups had frozen the month when its lines were read, Unix ms; 0 when nothing was frozen. |

### JoinCodeMaterial

What the host needs to render and rotate the join code locally.

| Field | Type | Description |
|---|---|---|
| `version` | string | "1". |
| `regionHint` | string | One char. |
| `sessionCode` | string | 7 chars; the host builds the 15-character join code around it. |
| `secret` | string (base64) | 32 bytes; HMAC key for the TOTP component. |
| `stepS` | number | Config.`joinCodeRotation`, in the whole seconds the counter uses. |
| `joinUrl` | string | The project's joinUrl with "#" appended; the SDK appends the code. |

### JoinGrant

A join grant: opaque, session-bound, single-use, and holding no place.

| Field | Type | Description |
|---|---|---|
| `grant` | string | The grant itself: base64url, unpadded, of 32 random bytes. |
| `expiresAtMs` | string (64-bit integer) | Absolute Unix ms on the service's clock past which the grant no longer redeems. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered, the anchor `expiresAtMs` is read against. |

### JoinGrantEntry

The grant half of `JoinRequest.entry`: the grant and the holder secret, which arrive together or not at all.

| Field | Type | Description |
|---|---|---|
| `grant` | string | As `JoinGrant.grant`. |
| `holder` | string | Base64url, unpadded, of the 32-byte holder secret whose hash the reservation carried. |

### JoinReserve

What a page asks a join grant for: the hash of the holder secret it keeps and will present when it redeems.

| Field | Type | Description |
|---|---|---|
| `holderHash` | string | Base64url, unpadded, of the SHA-256 of the 32-byte holder secret the page generated. |

### KeyLimits

A key's `limits{}`: every limit is spelled in camelCase, the spelling a refusal carries in `details.limit`.

| Field | Type | Description |
|---|---|---|
| `concurrentSessions` | number | How many sessions this key may hold open at once. |
| `concurrentClients` | number | How many clients this key's sessions may hold at once. |
| `sessionOpensPerMinute` | number | How many sessions this key may open in a minute. |
| `clientMinutesPerDay` | number | How many client minutes a day this key may spend. |
| `ticketMintsPerMinute` | number | How many tickets this key may mint in a minute. |
| `tokensMaxTtl` | string | The ceiling on a mint's `ttl` and on a live token's effective expiry, clamped `min`. |
| `requireToken` | string | The ordered enum "off" < "token" < "token_bound_to_session": a key may require, never relax, and what it requires reaches that key's sessions only. |
| `joinsPerMinuteKey` | number | `joinsPerMinuteKey`: joins one client key admits in a minute, across every session and address it joins from. |
| `bytesPerSecond` | number | How many bytes a second each client joining with this key may send in each direction. It may sit under the project's value and never above it. |
| `messagesPerSecond` | number | How many messages a second each client joining with this key may send. It may sit under the project's value and never above it. |
| `linkMessagesPerSecond` | number | The link ceiling, per client of the key: may sit under the project's and never under this key's own `messagesPerSecond`. |

### KeyView

A key, masked, or once with its full string.

| Field | Type | Description |
|---|---|---|
| `keyId` | string | The key's id, which is what every route names it by. It is not the key string. |
| `type` | string | "publishable" \| "secret" \| "admin". |
| `orgId` | string | The organization the key belongs to. |
| `projectId` | string | Publishable and secret keys only. |
| `projects` | array of string | Organization keys only; empty means every project in the organization. |
| `masked` | string | The key as a listing may show it. A secret or organization key is truncated, `bsk_sk_a7k2m9xq...jy2k`; a publishable key is the one class stored and listed in clear, so its `masked` is the whole key. |
| `secret` | string | The one-time full string; empty otherwise. |
| `status` | string | "active" \| "rotating" \| "revoked". |
| `label` | string | Your own name for the key. |
| `origins` | array of string | The origins a publishable key may be used from. |
| `limits` | [KeyLimits](#keylimits) | The caps this key is held to. |
| `createdAt` | string (64-bit integer) | When the key was created, Unix ms. |
| `createdBy` | string | Who created it: the console user, or the key that minted it. |
| `lastUsedAt` | string (64-bit integer) | When the key was last presented, Unix ms. Zero on a key that has never been used. |
| `retireAt` | string (64-bit integer) | When a rotating key stops being accepted, Unix ms. Zero on a key that is not rotating. |
| `successorKeyId` | string | The key that replaced this one in a rotation. |
| `revokedAt` | string (64-bit integer) | When the key was revoked, Unix ms. Zero while it is live. |
| `revokedReason` | string | "manual" \| "rotation" \| "compromise". |
| `permissions` | array of string | The permission list the key holds: `["sessions:open"]` on a host key, `["sessions:join"]` on a client key. |
| `expiresAt` | string (64-bit integer) | When the key expires, Unix ms; 0 on a key that carries no expiry (optional on every class, capped at one year, 90 days by default on an organization key; the login key has none). |
| `ancestry` | array of string | The keys that made this one, root first, ending with its immediate maker. |
| `hostKeys` | array of string | A client key's `conditions.hostKeys`: the host keys whose sessions it may join. |
| `subjects` | array of string | A key holding `tokens:mint`: its `conditions.subjects`, the subject prefixes a mint under it may name. |
| `approvedBy` | string | On the login key, the organization key `bitskiff login` and `bitskiff init` make: the console user who approved the sign-in. |

### LimitQuota

One cap, as it applies to the credential that asked.

| Field | Type | Description |
|---|---|---|
| `value` | string (64-bit integer) | The cap itself. |
| `scope` | string | Which level set the value: `project`, `key`, or `org` when the organization's plan holds it under the project's own, so a plan change and not a project raise is what lifts it. |
| `used` | string (64-bit integer) | The current period's consumption; 0 for a rate limit, which has no period to spend. |
| `periodEnd` | string (64-bit integer) | Ms since epoch when `used` resets; 0 for a rate limit. |

### LimitRaiseView

One pending limit raise: a project asked for a value above its organization's plan ceiling (`POST /v1/project/limits/raise`), and the current value applies until an organization owner approves or denies it.

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project that asked, as `proj_...`. |
| `projectName` | string | That project's name, for a person reading the list. |
| `limit` | string | The limit in its parameter spelling (`concurrentSessions`), as a refusal's `details.limit` names it. |
| `value` | string (64-bit integer) | The value asked for. |
| `current` | string (64-bit integer) | The project's own value, which applies while the raise is pending. |
| `ceiling` | string (64-bit integer) | The plan's ceiling for the limit now; absent when the plan no longer bounds it. |

### LostNode

The custom engine process a client's link was on when the engine said it is gone or going: the `node` and `boot` of the `bye` that ended the link, copied as they arrived.

| Field | Type | Description |
|---|---|---|
| `node` | string | The node's name, as the `bye` named it. |
| `boot` | string | The boot id of the process on that node, as the `bye` named it. |

### MemberView

One member of an organization, as `GET /v1/orgs/{orgId}/members` lists them and the `PATCH` and `DELETE` on one member answer them.

| Field | Type | Description |
|---|---|---|
| `userId` | string | The `user_` id of the console user. |
| `email` | string | The verified address the console user signs in with; empty when the store holds no record of them. |
| `role` | string | The organization role the relations amount to, highest first: `org_owner`, `org_admin`, `billing_admin` or `org_member`. |
| `relations` | array of string | The organization relations held directly, from `owner`, `admin`, `billing_admin` and `member`; a relation computed from another (an owner is an admin) is not repeated here. |
| `joinedAt` | string (64-bit integer) | Unix ms the first of those relations was written; 0 when the store kept no time. |
| `caller` | boolean | True on the row of the console user making the request, whose own role they may not change. |

### OrgPlan

The plan on an organization's record.

| Field | Type | Description |
|---|---|---|
| `name` | string | The plan's name, such as `experimental` or `unlimited`. |
| `ceilings` | object, string keys to string (64-bit integer) | The plan's values, keyed by the catalogue parameter (`concurrentSessions`). A row absent here has no plan bound. |
| `expiresAt` | string (64-bit integer) | Unix ms; absent means it does not expire. |
| `setAt` | string (64-bit integer) | Unix ms. |
| `setBy` | string | Who wrote it: a `user_` id, a service account, or `system`. |

### OrgView

One organization, as `GET /v1/orgs` and `POST /v1/orgs` answer it. It carries nothing about the caller: which relation they hold in it is what `GET /v1/auth/principal` answers.

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization's id. |
| `name` | string | The organization's name. |
| `createdAt` | string (64-bit integer) | Unix ms. |

### OrganizationMembership

One organization the user holds a relation in, with the highest one they hold there.

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization's id. |
| `name` | string | The organization's name. |
| `relation` | string | The highest of "owner", "admin", "member". |

### ParticipantHolder

The process that keeps a place's resume token.

| Field | Type | Description |
|---|---|---|
| `kind` | string | The kind of process that keeps the place: "page" \| "app". |
| `next` | [HandoffSuccessor](#handoffsuccessor) | The successor a handoff named, while that handoff is under way; absent otherwise. |

### ParticipantMeters

One client's own meters, as the service has them: which client it is, and its counts.

| Field | Type | Description |
|---|---|---|
| `participantId` | string | The client these meters are for, by its id on this wire. |
| `counters` | object, string keys to string | That client's counters by meter name. Each count is a string, because these are 64-bit numbers. |

### ParticipantStats

One client's own entry in a stats report: which client it is, and what it counted.

| Field | Type | Description |
|---|---|---|
| `participantId` | string | The client these counters are for, by its id on this wire. |
| `stats` | [Stats](#stats) | What that client reported over the window. |

### ParticipantView

One client of a session, as the service holds it: what it may do, the place it holds, and where it is.

| Field | Type | Description |
|---|---|---|
| `participantId` | string | The client's id on this wire. |
| `grants` | array of string | What this client may do: send input, receive input, broadcast, receive broadcasts, manage. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |
| `opener` | boolean | True for the host, which holds the place the session was opened on. |
| `seatClass` | string | Which kind of place this client holds, `"controller"` \| `"viewer"` \| `"opener"`. It follows the grants the client actually holds, so a client whose grants change changes class with them. |
| `seat` | number | Which place of that class this client holds, counted from one. It is stable across a resume, so the second controller to join is `2` for as long as it holds its place. |
| `state` | string | Where the client is: one of `joining`, `connected`, `reconnecting`, `released`, `away` or `gone`. |
| `presence` | string | Whether the client is in front of the person using it or backgrounded. One of `PRESENCE_PRESENT`, `PRESENCE_AWAY`. |
| `joinedAt` | string (64-bit integer) | When the client joined, Unix ms. |
| `sdk` | string | Which SDK the client joined with and its version, such as `ts-client/0.1.0`. |
| `transportPath` | string | The path this client last reported in its stats: `"udp"`, `"tcp"`, `"turn"` or `"unknown"`. It is `"unknown"` until the client reports one, and stays `"unknown"` for a client whose runtime cannot see which pair it is on. |
| `bundle` | string | The bundle this client joined with. One of `BUNDLE_CONTROLLER`, `BUNDLE_VIEWER`, `BUNDLE_HOST`. |
| `subject` | string | The subject of the join token this client presented: the developer's own user id, straight off this client's own record. |
| `goneReason` | string | Why this client is gone: a `gone`-surface reason of the reasons registry, straight off the client record. |
| `limits` | [SessionLimits](#sessionlimits) | This client's own traffic caps: `min(project row, the client key's own row)`, computed at join. |
| `connection` | string | The record's connection, "reserved" \| "attached" \| "detached" \| "released" \| "gone". |
| `keyId` | string | The id of the client key that admitted this client (`key_...`), never the key itself. |
| `origin` | string | The origin this client was admitted from: the `Origin` header a browser sent, or the origin a native or app-shell client declared where the header was absent or `null`; for the keyless sample client page, that page's own origin. |
| `originSource` | string | How `origin` arrived: "header" for the `Origin` header a browser sent, "declared" for the origin a native or app-shell client named in its request body. |
| `releasedTransport` | string | The transport that carries a released client (connection "released"), as its host named it at the release; empty for every other connection. |
| `holder` | [ParticipantHolder](#participantholder) | Who keeps this place: the holder's kind and, while a handoff is under way, its successor. |
| `entry` | string | How this client's place was entered, one of these values: "code" (a rotating join code) or "fixed". |
| `keyThumbprint` | string | The `cnf.jkt` the developer's backend bound into this client's join token at the mint: the RFC 7638 thumbprint of the key the client must present. |
| `grantsCeiling` | array of string | The grants ceiling of the join token that admitted this client: the most its grants can ever be, since admission and every grant change are clamped to it. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |
| `environment` | [ClientEnvironment](#clientenvironment) | The environment this client stated on its latest join, resume or committed handoff, as the service checked it against the os-family and client-runtime registries. |
| `metadata` | object, string keys to string | The developer's own metadata for this client, as that same request passed it, inside the client metadata bounds. |

### PlanRequest

A plan as staff write it: a named template (`experimental`, `unlimited`), resolved into values when it is written, with single rows overridden.

| Field | Type | Description |
|---|---|---|
| `name` | string | The named plan to set. Its template resolves into values on the organization's record. |
| `ceilings` | object, string keys to string (64-bit integer) | Overrides on the template, keyed by the catalogue parameter (`concurrentSessions`), the one spelling on every wire surface; only rows a project may raise. |
| `expiresAt` | string (64-bit integer) | Unix ms. |

### PlatformCostLeg

One cost leg for one organization's month.

| Field | Type | Description |
|---|---|---|
| `leg` | string | The leg's name as writes it. |
| `quantity` | number | The month's quantity summed over the organization's projects, in `unit`. |
| `unit` | string | The unit of `quantity`: a minute leg's minutes, GB, messages or requests. |
| `quantityKind` | string | What the quantity is: `measured`, `client reported`, `an estimate`, or `not metered per project`. |
| `unitCost` | number | What one unit costs Bitskiff in dollars. |
| `unitCostKind` | string | What the unit cost is: `an assumption`, `a parameter`, `free to Bitskiff`, or `none stated`. |
| `cost` | number | The product of quantity and unit cost, in dollars. |
| `costKind` | string | What the cost is: `an estimate` where a byte leg is priced at the egress price, `an assumption` where a minute leg is, `free to Bitskiff`, or empty when there is no cost. |
| `inTotal` | boolean | Whether `cost` is in the response's total. |
| `note` | string | Why the leg reads as it does, in one line; empty when nothing needs saying. |

### PlatformOrgView

One organization on the staff list: its plan record, who owns it, and what it is running now.

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization, as `org_...`. |
| `name` | string | The organization's name. |
| `createdAt` | string (64-bit integer) | Unix ms. |
| `ownerEmails` | array of string | The address of every organization owner, sorted. |
| `plan` | [OrgPlan](#orgplan) | The plan record as it stands. |
| `expired` | boolean | Whether the plan has passed its expiry at answer time. |
| `projectCount` | number | How many projects the organization holds. |
| `liveSessions` | number | Live sessions across its projects, read from the gates. |
| `liveClients` | number | Clients holding a place in those sessions, from the gates. |
| `clientSlots` | number | The client slots the plan commits: its `concurrentClients` ceiling, counted at organization grain. |
| `platformSlots` | number | The platform ceiling's slots: `platformConcurrentParticipants`, hosts and clients together. |

### PlatformSdkRelease

One release on the platform list, with the organizations that ran it in the last 30 days.

| Field | Type | Description |
|---|---|---|
| `release` | [SdkRow](#sdkrow) | The release, its sessions counted across every organization. |
| `orgIds` | array of string | The organizations that ran it, ordered by id. |
| `refusedOrgs` | number | Organizations that ran it and refused staff access: counted here, never named in `orgIds`. |
| `orgs` | array of [PlatformSdkReleaseOrg](#platformsdkreleaseorg) | The organizations `orgIds` names, each marked when it is the platform owner's. |

### PlatformSdkReleaseOrg

One organization that ran a release, as the platform list names it.

| Field | Type | Description |
|---|---|---|
| `orgId` | string | The organization's id. |
| `owner` | boolean | True for the platform owner's organization (the bootstrap organization), whose apps are never live apps. |

### ProjectEvent

One event of the project's stream, the same object the webhook delivers to your URL.

| Field | Type | Description |
|---|---|---|
| `id` | string | The event's own id, which is what a webhook delivery is deduplicated on and what this stream is paged by. |
| `type` | string | Which kind of event this is, such as `open.refused`. |
| `at` | string (64-bit integer) | When the event happened, Unix ms on the service's clock. |
| `data` | string | The event's own fields, JSON-encoded: the same bytes the webhook delivery carries under `data`. |

### ProjectMembership

One project the user holds a relation on, with every project relation they hold there.

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project's id. |
| `slug` | string | The project's slug, as the console's address names it. |
| `orgId` | string | The organization the project is in. |
| `relations` | array of string | Each project relation this person holds there, of `admin`, `key_manager`, `editor` and `viewer`. `editor` brings `viewer` with it, and `admin` brings all four. |

### ProjectView

One project: how it is named and addressed, and the settings every session it opens inherits.

| Field | Type | Description |
|---|---|---|
| `projectId` | string | The project's internal key. The slug is the handle you type. |
| `orgId` | string | The organization the project belongs to. |
| `restrictedMode` | boolean | True when this project takes no publishable-key opens: a session must be opened with a secret key or a token. |
| `joinUrl` | string | The page a join code points a client at. Without one, an open is refused `join_url_missing`. |
| `e2ee` | string | "off" \| "required". |
| `name` | string | The caller's own word for the project, 1-64 characters, not unique. |
| `createdAt` | string (64-bit integer) | When the project was created, Unix ms. |
| `slug` | string | The project's public handle: globally unique, immutable, lowercase letters, digits and hyphens, 5 to 30 characters. |
| `archivedAt` | string (64-bit integer) | When the project was archived, Unix ms; absent on a project that is not. An owner can unarchive it for 30 days after this. |

### ProtocolRange

The span of protocol versions one end can speak, oldest to newest.

| Field | Type | Description |
|---|---|---|
| `min` | number | The oldest protocol version the caller can speak. |
| `max` | number | The newest protocol version the caller can speak. |

### ProtocolVersion

One protocol version the service still speaks, and when it stops.

| Field | Type | Description |
|---|---|---|
| `version` | number | The protocol version. |
| `status` | string | "current" \| "supported" \| "sunset". |
| `sunset` | string (64-bit integer) | When the service stops speaking it, Unix ms. Zero while no date is set. |

### RegisteredApp

A native app a project registered for one platform: the only app an offer may name, and the only client key that may redeem a handoff ticket.

| Field | Type | Description |
|---|---|---|
| `platform` | string | `android` or `ios`. |
| `appId` | string | The Android package name, or the iOS bundle id. |
| `fingerprints` | array of string | Android: the SHA-256 signing certificate fingerprints, colon-separated upper-case hex, as `assetlinks.json` spells them. |
| `storeId` | string | IOS only: the numeric App Store id the listing is built from. |
| `handoffLink` | string | The https handoff link, on a host the app claims (`assetlinks.json`, `apple-app-site-association`) and the page is not served from. |
| `clientKeyId` | string | The id (`key_`) of the publishable client key the app joins with. |
| `name` | string | The app's name as the offer shows it: "Continue in the {name} app". |
| `gain` | string | What the app gives, in the developer's words, at most 40 characters: the offer's one line. |

### RegisteredAppListPatch

A presence-carrying wrapper for the registered app list, as `TerminationRuleListPatch` is for rules: absent leaves the list alone, present replaces it whole, present with no `values` clears it.

| Field | Type | Description |
|---|---|---|
| `values` | array of [RegisteredApp](#registeredapp) | The registered apps, one per platform; empty clears the list. |

### ReleaseDigest

One SDK tarball of a release: the download it names and the sha256 of its bytes.

| Field | Type | Description |
|---|---|---|
| `package` | string | The package name, such as `@bitskiff/host`. |
| `file` | string | The download's path under `/downloads/`, such as `0.2.2/bitskiff-host.tgz`. |
| `sha256` | string | The lowercase hex sha256 of the tarball's bytes. |
| `bytes` | string (64-bit integer) | The tarball's size in bytes. |

### RelocatedClient

One client a re-placement is still waiting for.

| Field | Type | Description |
|---|---|---|
| `participantId` | string | The client's client id. |
| `releasesAtMs` | string (64-bit integer) | Absolute Unix ms its `relocate` deadline releases its place at. |

### Relocation

The answer to a host-ticket that re-placed its session, or found it already re-placed.

| Field | Type | Description |
|---|---|---|
| `relocatedAtMs` | string (64-bit integer) | Absolute Unix ms on the service's clock the session was last re-placed at (`relocatedAt`). |
| `clients` | array of [RelocatedClient](#relocatedclient) | Each client not yet on the new node, with the instant its `relocate` deadline releases its place: the held time the host shows for a client it reports `disconnected`. |

### ReservationRefused

A refused join-grant reservation: the refusal a route would have answered, carried beside an inspect that still answers.

| Field | Type | Description |
|---|---|---|
| `code` | string | `rate_limited` today: one of the two outstanding-grant ceilings is full. |
| `retryAfterMs` | number | How long to wait before reserving again: until the soonest outstanding grant expires. |
| `details` | object, string keys to string | `limit` (`joinGrantsOutstanding` or `joinGrantsOutstandingSession`), `scope`, `value` and `ceiling`, as on every limit refusal. |

### RttSummary

Round trips over one window, summarised: the shape of the window, and the totals since the client joined.

| Field | Type | Description |
|---|---|---|
| `windowCount` | number | Samples behind the window figures. |
| `windowP50Ms` | number | Median round trip over the window, in milliseconds. |
| `windowP95Ms` | number | 95th-percentile round trip over the window, in milliseconds. |
| `windowMaxMs` | number | The slowest round trip in the window, in milliseconds. |
| `totalCount` | string (64-bit integer) | How many round trips have been measured since this client joined. |
| `totalSumMs` | string (64-bit integer) | Those round trips added together, in milliseconds, so an average over the whole client can be taken. |

### SamplePageSessions

The sample page's lifetime count for one project. One session is counted each time the project opens one while its `joinUrl` is the sample page, and nothing resets the count.

| Field | Type | Description |
|---|---|---|
| `used` | string (64-bit integer) | Sessions counted for the life of the project. |
| `onboardingBound` | number | Past this count every read of the project's settings warns, and nothing is refused -- `100`. |
| `refusalBound` | number | Past this count a join through the sample page is refused until `joinUrl` names a page of the project's own -- `1000`. |
| `onSamplePage` | boolean | Whether the project's `joinUrl` is the sample page now. A project that moved to a page of its own keeps its count, and nothing more is counted. |

### SdkRow

One SDK release as the inventory saw it in the last 30 days: a package and version, from `X-Bitskiff-SDK` on a ticket mint, and the protocol version that mint negotiated.

| Field | Type | Description |
|---|---|---|
| `sdk` | string | The whole tag, `<package>/<semver>`, e.g. |
| `protocol` | number | The protocol version the mints pinned. |
| `lastSeen` | string (64-bit integer) | Unix ms of the latest mint that carried this release. |
| `sessions30d` | string (64-bit integer) | Distinct sessions this release minted for in the last 30 days. |
| `package` | string | The `<package>` half of the tag, e.g. |
| `version` | string | The `<semver>` half, e.g. |
| `protocolStatus` | string | That version's status in `GET /v1/protocol`: "current" \| "supported" \| "sunset". |
| `sunset` | string (64-bit integer) | Unix ms that version is refused from; 0 while it has no sunset date. |

### SessionConfig

Requested at open, clamped by the service against project ceilings, echoed everywhere.

| Field | Type | Description |
|---|---|---|
| `maxClients` | number | Capacity is one number and there are no per-role ceilings: a session holds this many clients at once and the next join is refused `clients_full`. |
| `reconnectBudget` | string | How long a client may spend reconnecting before its place is given up, as a duration string: `15s`, `2m`. |
| `backgroundBudget` | string | How long a client may spend backgrounded before its place is given up, as a duration string: `5m`, `30m`. |
| `maxSessionTime` | string | How long the session may run before the service ends it, as a duration string. `0s`, the default, is no limit. |
| `idleTimeout` | string | `0s` = unlimited: the idle limit is off unless asked for. |
| `joinCodeRotation` | string | How often the join code rotates, as a duration string: `30s`, `2m`. It rotates on a whole number of seconds, so a step that is not one is refused. |
| `hostResumeWindow` | string | The host grace: how long the service keeps the session open for a host that dropped, as a duration string. At open no host has arrived to drop, so the session waits out the opener's own ticket instead -- 60 s -- whatever you send here, which means a session opened ahead of its host holds a concurrency slot for that minute. From the first host attach on, this is the whole grace, and `0s` ends the session the moment the host is gone. |
| `warningLead` | string | Warning lead: how long before a terminal deadline each SDK raises its own warning. |
| `defaultGrants` | array of string | The grants a new client starts with. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |
| `terminationRules` | array of [TerminationRule](#terminationrule) | The session's termination policy: at most 16 rules, and the empty list, which is the default, is the platform's two budgets and nothing else. |

### SessionDeadlines

Every session-scoped deadline the service has armed, as absolute Unix ms on the service's clock; 0 is not a deadline in the past, it means this one is not armed.

| Field | Type | Description |
|---|---|---|
| `idleDeadlineMs` | string (64-bit integer) | The idle limit: no client attached since this was armed, and at this instant the session ends `idleTimeout`. |
| `maxTimeDeadlineMs` | string (64-bit integer) | `maxSessionTime` measured from open. |
| `codeRotatesAtMs` | string (64-bit integer) | The next join-code step boundary. |
| `holdReleaseAtMs` | string (64-bit integer) | A hold taken with a release instant; 0 for a hold that stands until somebody releases it. |
| `releasedCeilingDeadlineMs` | string (64-bit integer) | The released-session bound: while the host is released, at this instant the session ends released_session_ceiling. |

### SessionDefaults

The session-config fields a project may give a default: the numeric rows of the session-config registry, spelled the way SessionConfig spells them.

| Field | Type | Description |
|---|---|---|
| `maxClients` | number | How many clients a session holds at once; 0 in a patch leaves the default alone. |
| `reconnectBudget` | string | Duration string; the empty string in a patch leaves the default alone. |
| `backgroundBudget` | string | Duration string; the empty string in a patch leaves the default alone. |
| `hostResumeWindow` | string | Host grace, a duration string; `0s` ends a session with its host. |
| `idleTimeout` | string | The idle limit, a duration string; `0s` is unlimited. |
| `maxSessionTime` | string | A duration string; `0s` is no maximum. |
| `joinCodeRotation` | string | A duration string of whole seconds. |
| `warningLead` | string | A duration string; `0s` asks for no warning. |

### SessionLimits

The limits a session hands its clients, so an SDK learns the cap it has to self-enforce from a response it already receives.

| Field | Type | Description |
|---|---|---|
| `bytesPerSecond` | number | The `bytesPerSecond` row, per client per direction. |
| `messagesPerSecond` | number | The `messagesPerSecond` row's `bitskiff.in` rate, per client: 120 unless the project lowered it. |
| `linkMessagesPerSecond` | number | The `linkMessagesPerSecond` row, per client: the link ceiling, the class rate a client's lossy channels run at while its direct link is up. |

### SessionUsage

One session's usage: what a developer bills its own customers on. The same fields and the same `id` on the ledger, the `session.ended` webhook and the session view. Durations are milliseconds and instants Unix ms; how to round them is your own choice.

| Field | Type | Description |
|---|---|---|
| `id` | string | The record's one identifier: the event id of the `session.ended` that finalized it, the same on the ledger, the webhook, the view and the CSV, so dedupe on it. A day record's is `{sessionId}:{YYYY-MM-DD}`. Empty while the session runs: never bill a record without one. |
| `sessionId` | string | The session. |
| `projectId` | string | The project the session belongs to. |
| `region` | string | The region the session ran in. |
| `keyId` | string | The key that opened the session, or minted the token that did. |
| `subject` | string | The subject the session was opened under: your own id for the customer the host ran for. Empty when no token opened it. |
| `label` | string | The host's label for the session, as it ended. |
| `metadata` | object, string keys to string | The session's own keys and values, as it ended. |
| `openedAt` | string (64-bit integer) | When the session was opened, Unix ms. |
| `endedAt` | string (64-bit integer) | When the session ended, Unix ms. On a day record it is set only on the day the session ended; absent while it runs. |
| `endedReason` | string | Why the session ended, a session end reason. Empty while it runs, and on a session whose end never reached the service, which the ledger closes at its last known state. |
| `attachedMs` | string (64-bit integer) | Time with a host attached, in milliseconds. |
| `detachedMs` | string (64-bit integer) | Time in host grace or with no host at all, in milliseconds. |
| `seatMs` | string (64-bit integer) | Client time in milliseconds: every client's time connected, away or disconnected, summed over every bundle. Joining is not counted. |
| `seatMsByBundle` | object, string keys to string (64-bit integer) | The same client time, one entry per bundle name, for a price that differs by bundle. |
| `clients` | number | Clients admitted: one per accepted join, so a client that returned after it was gone counts twice. Dedupe people on the subject of the client records. |
| `peakClients` | number | The most clients held at once. |
| `bytesReported` | string (64-bit integer) | Bytes through the engine on every channel, direction and path, as the host reported them. A display figure, never a price. Bytes on peer or a declared transport are logged, never here. |
| `dataBytesDown` | string (64-bit integer) | Message bytes the service sent to the session's host and clients, as the service measured them: the byte figure to price on. A day record carries that day's; a session record the sum of its days. 0 on the webhook and the view. |
| `dataBytesUp` | string (64-bit integer) | Message bytes the service received from the session's host and clients, as the service measured them. A day record carries that day's; a session record the sum of its days. 0 on the webhook and the view. |
| `dataBytesGrade` | string | `server_measured` when the service measured message bytes for the session. Empty, with both byte fields 0, when it measured none: always on the webhook and the view, which close before the measurement lands, and on sessions before the measurement began. |
| `writtenAt` | string (64-bit integer) | When the ledger last wrote this record, Unix ms. Absent on the webhook and the view. A record that changed after you billed it carries a newer one. |
| `writerRevision` | number | Which revision of the ledger's writer wrote the record. Absent on the webhook, the view and a day record. |
| `day` | string (64-bit integer) | A day record only: the UTC day it covers, as Unix ms of that day's 00:00. Every quantity of a day record is that day's share. |
| `complete` | boolean | A day record only: false when the day's last two minutes held no snapshot of the session, so the day's share may be short by that much. |

### SessionView

One session, as the service holds it: its config as it was clamped, its state, and what it has running.

| Field | Type | Description |
|---|---|---|
| `sessionId` | string | The session's id. |
| `projectId` | string | The project the session belongs to. Filled for the developer only (a secret key, an organization key or a console session); empty to a page (protocol.md 9.9). |
| `config` | [SessionConfig](#sessionconfig) | Clamped. |
| `ceilings` | [SessionConfig](#sessionconfig) | What the project may request. |
| `label` | string | Your own name for the session: what it was opened with. |
| `metadata` | object, string keys to string | Your own keys and values, as the session was opened with. |
| `region` | string | The region the session runs in. |
| `hostEpoch` | number | Counts up each time a host attaches to this session. A message carrying a lower epoch than the one already seen is a late copy, and is dropped. |
| `state` | string | Where the session is: one of `open`, `host_attached`, `host_away`, `host_released` or `ended`. |
| `openedAt` | string (64-bit integer) | When the session was opened, Unix ms. |
| `endedAt` | string (64-bit integer) | 0 while live. |
| `endedReason` | string | Why the session ended, and absent while it runs. One of `host_closed`, `api_closed`, `idle_timeout`, `max_session_time`, `released_session_ceiling`, `host_resume_expired` or `lost` -- the same session end reasons the SDKs and the close codes carry: see [Session end reasons](/docs/guides/lifecycle/#session-end-reasons). |
| `participants` | array of [ParticipantView](#participantview) | The clients the session holds. Present on a read of one session and on close, and left out of the session list, which answers many sessions at once. |
| `deadlines` | [SessionDeadlines](#sessiondeadlines) | 16 is left for the hold member the session hold routes add. |
| `serverTimeMs` | string (64-bit integer) | The service's own Unix ms at the moment it answered. |
| `e2ee` | boolean | Whether this session is end-to-end encrypted: its own mode, fixed when it opened and never changed after. `POST /v1/join/inspect` answers the same value before a client exists. |
| `controllersHeld` | number | How many of the clients held at that moment hold the input grant, counted off the session's own record. Every answer computes it, the session list included, so the console's clients-in-use column and `bitskiff sessions list` have the number without reading a roster; the `participants` list itself comes only from a read of one session. Like every scalar it is left out of the JSON at zero, so read an absent field as none rather than as unknown. |
| `viewersHeld` | number | How many of the clients held at that moment hold no input grant. |
| `subject` | string | The subject the session was opened under: the user id your backend minted the opening token for. Empty when no token opened it. |
| `keyId` | string | The id of the key that opened the session, or minted the token that did. Filled for the developer only (a secret key, an organization key or a console session); empty to a page. |
| `usage` | [SessionUsage](#sessionusage) | The session's usage record, on a read of one session only. While the session runs it is a running total with an empty `id`, never a figure to bill; once it has ended it is the final record, with the same `id` the `session.ended` webhook and the ledger carry, for as long as the session is kept. |
| `messagesToHost` | string (64-bit integer) | How many messages the session's clients have sent its host, control frames left out, as the host reported them. On a read of one session only. With a client in `participants` and a message each way, the session works end to end. |
| `messagesToClient` | string (64-bit integer) | How many messages the session's host has sent its clients, control frames left out, as the host reported them. On a read of one session only. |

### SignInProvider

One federated provider and whether it is on.

| Field | Type | Description |
|---|---|---|
| `name` | string | The provider: `google` or `microsoft`, as in `/v1/auth/sign-in/oidc/{provider}`. |
| `enabled` | boolean | True when this deployment holds a client id for the provider, so its sign-in can finish. |

### Stats

What one end counted over one window: the traffic it moved and the round trips it measured. A client reports it to its host every 60 seconds and once more as it leaves.

| Field | Type | Description |
|---|---|---|
| `windowMs` | number | What the RttSummary window figures cover; 60000 nominal, shorter for the report `leave` sends. |
| `traffic` | array of [TrafficCounter](#trafficcounter) | This client's own counts. |
| `rtt` | [RttSummary](#rttsummary) | Round trip over the window, summarised rather than sample by sample. |
| `path` | string | The selected candidate pair now: "udp" \| "tcp" \| "turn" \| "unknown". |
| `iceRttMs` | number | The engine's figure for the selected pair; 0 if none. |
| `joinToWelcomeMs` | number | First report only, 0 after: ticket response to `Welcome`, the one interval the service cannot measure. |

### StringListPatch

A list you are replacing. Leave the field out to leave the list alone; send this object to replace the list with the values it carries.

| Field | Type | Description |
|---|---|---|
| `values` | array of string | The whole new list. Sent with no values, it clears the list. |

### TerminationRule

One rule of a session's termination policy: a condition over the facts the service knows about one client, plus how long that condition must hold.

| Field | Type | Description |
|---|---|---|
| `name` | string | This developer's name for the rule, which is what every surface reports the firing under: 1 to 32 characters of [a-z0-9_-], unique in the list. |
| `when` | string | The condition, a CEL expression over the facts the service knows about one client, with no clock in it: the expression grammar is under `TerminationRule` in the [host SDK reference](/docs/packages/host/#session-types). It is checked when the config is set: at most 512 bytes, 1000 cost units and 16 levels deep, naming declared facts only, and evaluating to `bool`. An expression outside those bounds is refused `validation`, with `details.field` naming the rule and `details.reason` carrying the checker's own message. |
| `for` | string | How long `when` must hold continuously before the rule fires. |

### TerminationRuleListPatch

A presence-carrying wrapper for a rule list, the way `StringListPatch` is for a list of strings: proto3 has no `optional` on `repeated`, so a message field stands in.

| Field | Type | Description |
|---|---|---|
| `values` | array of [TerminationRule](#terminationrule) | The whole list, at most 16 rules, in the order a refusal's `terminationRules[n]` counts them. |

### Ticket

What one end connects its transport with: where to dial, the credential to dial with, what it may do there, and when it expires.

| Field | Type | Description |
|---|---|---|
| `sessionId` | string | The session this ticket is for. |
| `participantId` | string | The client this ticket is for, by its id on this wire. |
| `grants` | array of string | What this end may do once it is connected. The set is minted into the ticket and cannot be widened by the holder. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |
| `protocol` | number | The protocol version the service pinned for this session, which every end of it speaks. |
| `endpoint` | string | Where the transport connects, the same value `GET /v1/whoami` answers with. Hand it to the SDK as it is: the transport settles the scheme and the path it needs. |
| `token` | string | A signed token for the media engine, handed to the SDK unchanged. |
| `expiresAt` | string (64-bit integer) | Unix ms; 60 s after issue. |
| `region` | string | The region the transport is dialled into. |
| `jti` | string | Single-use id; consumed by the service on issue and reconciled on join. |
| `opener` | boolean | True for the host. |
| `apiId` | string | The sender identity the service itself signs with in this region, `a_<region>`: the only sender that is not another client and is still trusted. |
| `kind` | string | Which engine `endpoint` and `token` are for, fixed when the session opened: `custom`, the only engine since 0.3. Hand it to the SDK with the rest of the ticket: the SDK picks how to connect from it, and your code never needs to read it. |

### TokenConfirmation

The client's key thumbprint, bound into a join token for authenticated end-to-end key binding.

| Field | Type | Description |
|---|---|---|
| `jkt` | string | The SHA-256 JWK thumbprint of the client's key. |

### TokenLimits

The limits a mint binds into a token.

| Field | Type | Description |
|---|---|---|
| `concurrentSessions` | number | Defaults to 1 when the mint omits it. |
| `clientMinutesPerDay` | number | How many client minutes a day this token's subject may spend. |
| `concurrentClients` | number | Per subject; a join token's default is 1. |
| `sessionOpensPerMinute` | number | Per subject; default 3. |
| `joinsPerMinute` | number | Per subject; default 6. |

### TokenSummary

One row of GET /v1/tokens.

| Field | Type | Description |
|---|---|---|
| `tokenId` | string | The token's id, which is what `DELETE /v1/tokens/{tokenId}` names. |
| `subject` | string | Who the token was minted for: your own id for a person or a device. |
| `keyId` | string | The minting key. |
| `permissions` | array of string | What the token may do, as a narrowing of the key that minted it. |
| `mintedAt` | string (64-bit integer) | When the token was minted, Unix ms. |
| `expiresAt` | string (64-bit integer) | The token's own expiry, before the project's current ceiling. |
| `sessionId` | string | A join token's binding, or empty. |
| `grantsCeiling` | array of string | A join token's grants ceiling, or empty for none. One of `GRANT_SEND_INPUT`, `GRANT_RECEIVE_INPUT`, `GRANT_BROADCAST`, `GRANT_RECEIVE_BROADCAST`, `GRANT_MANAGE`. |

### TrafficCounter

Traffic one endpoint counted for one client, one topic, one direction and one transport path.

| Field | Type | Description |
|---|---|---|
| `topic` | string | "ctl" \| "in" \| "out". |
| `direction` | string | "to_host" \| "to_client", named from the client's side. |
| `path` | string | "udp" \| "tcp" \| "turn" \| "unknown" while these were counted on the engine, and "direct" for the direct link. |
| `messagesCount` | string (64-bit integer) | How many messages were counted. |
| `bytes` | string (64-bit integer) | How many bytes were counted. |
| `droppedCount` | string (64-bit integer) | SDK-side drops. |
| `transport` | string | The transport this row's traffic rode, by name: "server", "peer" or a declared transport's name; empty reads as "server". |

### TrafficTotals

A count of messages and the bytes they carried.

| Field | Type | Description |
|---|---|---|
| `messages` | string (64-bit integer) | How many messages. |
| `bytes` | string (64-bit integer) | How many bytes, as the host reported them. |

### TransportTraffic

One transport's host-reported traffic on a session, summed over topics.

| Field | Type | Description |
|---|---|---|
| `name` | string | The transport's name: `server`, `peer`, or the name the host declared, such as `bluetooth`. |
| `in` | [TrafficTotals](#traffictotals) | What went to the host on this transport. |
| `out` | [TrafficTotals](#traffictotals) | What went to clients on this transport. |

### UsageRow

One bucket of usage: when it starts, and what each meter counted in it.

| Field | Type | Description |
|---|---|---|
| `bucketStart` | string (64-bit integer) | Start of the UTC day or month. |
| `meters` | object, string keys to number | Meter name -> value, in the metering vocabulary. |

## Next steps

- [Quickstart](/docs/quickstart/)
- [The host package](/docs/packages/host/)
- [The client package](/docs/packages/client/)
- [The CLI](/docs/packages/cli/)