# Limits and capacity

Every number a project runs into, what it refuses, and what clears it.

## Session capacity

Capacity is one session's own setting, not a project quota. You set it on the open.

| Setting | Default | Ceiling | How to set it | What a client sees when it is full |
|---|---|---|---|---|
| `maxClients` | 4 | 500 | `open({ maxClients: 8 })` | `join()` is refused with a `BitskiffError` whose `code` is `clients_full`. The sample client page says the session is full |

```js
import { BitskiffHost } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open({ maxClients: 8 });
```

```ts
import { BitskiffHost, type Session } from '@bitskiff/host';

const session: Session = await new BitskiffHost({ key: 'bsk_pk_...' }).open({ maxClients: 8 });
```

A new project holds 4 clients in a session opened at the default, and 40 clients across all its
sessions at once. Those are the numbers to plan against until you raise something.

Two bounds sit over one session. The session's own `maxClients` is what you set on the open, and
its ceiling is 500. The project's `concurrentClients` counts every client in every session at once,
it is 40 by default, and a raised session meets it first: a session opened with `maxClients: 100`
still refuses the 41st client in the project with `concurrency_limit`.

The fifth client at a default session is refused by `maxClients` instead, so a demo with more
devices than four sets `maxClients` on the open. Ask for a [`concurrentClients`](#quotas) raise to match whatever capacity
you set here; [Raises](#raises) says when one applies at once.

### Other session settings

The other seven durations the open takes, each with its platform default and the ceiling a project
may move it inside. Every one is a duration string -- `500ms`, `90s`, `5m`, `1h30m`: see
[Durations](#durations) for the grammar and what a bad one does. A value above the ceiling is
clamped, not refused.

A value that was not clamped comes back spelled the way you wrote it, and a
clamped one comes back as the clamp in canonical form, because that value is the platform's and not
yours. `session.state.config` is what you actually got.

| Setting | Default | Ceiling | What it is |
|---|---|---|---|
| `reconnectBudget` | `10s` | `2m` | how long a client that dropped in the foreground keeps its place |
| `backgroundBudget` | `30s` | `30m` | how long a client whose page went to the background keeps its place |
| `hostResumeWindow` | `10s` | `30m` | the host grace: how long the session waits for its host, clients and join code intact. `0s` ends the session with the host |
| `idleTimeout` | `0s`, no idle limit | `24h` | the idle limit: no client joined for this long ends the session |
| `maxSessionTime` | `0s`, no limit | none | the longest a session may run |
| `joinCodeRotation` | `30s` | `2m` | how often the join code rotates, in whole seconds. The floor is `15s` |
| `warningLead` | `30s` | `5m` | how long before a deadline each SDK raises its own warning |

## Durations

Every duration you write is a string: a session setting, a token's `ttl`, an SDK option, a CLI
flag. One grammar covers all of them.

A duration is one or more counts, each with a unit and nothing between them:

```js
import { BitskiffHost } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open({
  reconnectBudget: '20s',
  backgroundBudget: '2m',
  idleTimeout: '1h30m',
  maxSessionTime: '0s',
});
```

The rules:

- The units are `ms`, `s`, `m` and `h`. There is no day unit, because a day's length depends on a
  calendar.
- Units go from largest to smallest, and each appears at most once. `1h30m` is a duration, `30m1h`
  is not.
- No spaces and no sign. `1h 30m` and `-5s` are not durations.
- A count may pass its unit's natural roll: `90s` is a duration and means a minute and a half.
- A count may be decimal as long as the whole duration lands on a whole number of milliseconds.
  `2.5s` is a duration; `0.5ms` is not.
- Zero is written with a unit, like every other count: `0s`. It is the off switch on the settings
  that have one.
- A bare number is not a duration. There is no implied unit anywhere, so `90` is refused where
  `90s` is accepted.

| Written | Means |
|---|---|
| `500ms` | half a second |
| `90s` | a minute and a half |
| `5m` | five minutes |
| `1h30m` | ninety minutes |
| `0s` | zero |

**What comes back.** The api stores the string you sent and returns it on every read, so `'90s'`
comes back `'90s'` and never `'1m30s'`. The one exception is a value the platform changed: a setting
clamped to a ceiling comes back as the clamp, in canonical form, because that number is the
platform's and not yours.

**What a bad one does.** A string this grammar refuses is a `validation` error whose
`details.reason` is `duration`. It fails on the call, not later: `idleTimeout: '90'` and
`idleTimeout: '2d'` are both refused at the open.

An SDK checks its own written options before the
request goes out and names the option it read in `details.option`; the api refuses a bad duration it
receives -- a REST body, a CLI flag -- and names the field in `details.field`.

```js
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

try {
  await new BitskiffHost({ key: 'bsk_pk_...' }).open({ idleTimeout: '2d' });
} catch (err) {
  if (!isBitskiffError(err)) throw err;
  // 'validation', then 'idleTimeout' and 'duration'.
  console.log(err.code, err.details?.option, err.details?.reason);
}
```

Readings are the other half of the rule and they are not strings. Anything the platform measured or
counted down stays an integer of milliseconds and carries `Ms` in its name: `retryAfterMs`,
`rttMs`. You write strings; you read numbers.

## Quotas

A quota is a count against a ceiling. It refuses at the door and clears when something ends, when
the period rolls, or when you raise it. It never cuts a live session or a client already in one: a
project at its ceiling keeps serving every client already in a session, and stops opening the next
one.

A session whose host never arrives holds a `concurrentSessions` slot for its `hostResumeWindow`,
and never less than the 60 s life of the opener's ticket; it then ends `host_resume_expired`. So a backend that opens sessions ahead of their hosts can hold its whole
ceiling on sessions with nothing attached.

| Limit | What it refuses | `code` | What the live session sees | What clears it |
|---|---|---|---|---|
| `concurrentSessions`, 5 | the next `open()` | `concurrency_limit` | nothing | a session ends. `retryAfterMs` is 10000 |
| `concurrentClients`, 40 | the next join | `concurrency_limit` | nothing; the client's join is refused and the sample client page counts down | a client leaves or is booted. `retryAfterMs` is 10000 |
| `clientMinutesPerDay`, 2000 | new opens and new joins | `quota_exceeded` | a `capacity` row at 80%, `exceeded` at 100% | 00:00 UTC, or a raise. `resetAtUnixMs` carries the instant |
| `monthlySpendCap`, 60000 rated units | new opens and new joins | `quota_exceeded` | a `capacity` row at 80%, `exceeded` at 100% | the month rolls over, or a raise. A rated unit is a client-minute |
| `samplePageSessions`, 100 warn and 1000 refuse | joins through the sample client page, past 1000 sessions counted for the life of the project | `quota_exceeded` | a `capacity` row past 100, `exceeded` past 1000; live sessions keep their clients | pointing the project's `joinUrl` at a page of your own. No wait is given: `details.setting` is `joinUrl` |
| `samplePageJoinsPerDay`, 100 | joins through the sample client page, past 100 joins in one UTC day | `quota_exceeded` | nothing; the host learns of it only through a client's refused join | 00:00 UTC, or pointing the project's `joinUrl` at a page of your own |
| `turnBytesPerMonth`, 64 GiB | the relay credential for new direct links; joins and live links continue | `turn_quota_exceeded` | a `capacity` row at 80%, `exceeded` at 100%; the host is told once per client, `relayUnavailable` on `diagnostic` | the month rolls over, or a raise |

Relay is the fallback path a session takes when two peers cannot reach each other directly. A
client on another network -- cellular, a guest network -- runs through it.

## Rate limits

A rate limit is a count inside a window. It refuses the call in hand and nothing else, and the
window is the whole of the wait: `retryAfterMs` is the time left in it.

Two rows below count tickets. A ticket is the single-use credential the service mints when a host
or a client is about to connect; it expires in a minute and no SDK hands you one. Every open,
every join and every resume mints one, so the ticket rows are the rows a reconnecting fleet meets.

| Limit | What it refuses | `code` | What the live session sees | What clears it |
|---|---|---|---|---|
| `sessionOpensPerMinute`, 10 a minute per project and per key | `open()` | `rate_limited` | nothing | the window |
| `sessionOpensPerMinuteIp`, 30 a minute per IP address | `open()` from one address | `rate_limited` | nothing | the window. Ask for a raise to the number of hosts behind one address, up to 300 |
| `hostTicketsPerMinuteIp`, 30 a minute per IP address | a host ticket mint from one address, which is every resume attempt | `rate_limited` | nothing | the window. Ask for a raise to the number of hosts behind one address, up to 300 |
| `joinsPerMinuteSession`, 30 a minute | a join to one session | `rate_limited` | nothing | the window |
| `joinsPerMinuteKey`, 300 a minute | a join with one client key, across every session of its hosts | `rate_limited` | nothing | the window |
| `joinsPerMinuteIp`, 20 a minute per IP address | joins from one IP address | `rate_limited` | nothing | the window. Ask for a raise to the number of clients that join from one address in a minute, up to 200 |
| `joinsPerMinute`, 6 a minute per subject | a join presenting a join token | `rate_limited` | nothing | the window. A token's row only, set on the mint; the project has no such setting |
| `bootCooldown`, `10m` per subject | every join as a subject the host booted for going over its byte rate, 8 KiB a second by default (see [Channels](/docs/guides/channels/)), in every session of the project. A client booted this way ends with `ended.reason` `rate`, and the host sees `clientLeft` with `rate` | `rate_limited` | nothing | the cooldown. `retryAfterMs` is the time left in it. Your own `client.boot()` never starts it, and a client that joined without a token has no subject to hold |
| `badCodeAttempts`, 10 per 600 s | a join carrying a code or a join token that does not resolve | `rate_limited` | nothing | the back-off, which doubles from 2000 ms. Not settable per project |
| `clientResumesPerMinute`, 6 a minute | one client's resume and leave calls | `rate_limited` | nothing; that client waits the window out and every other client keeps its own budget | the window |
| `presenceChangesPerMinute`, 60 a minute per client | one client's presence changes | `rate_limited` | nothing; the change is not applied, and the SDK sends the latest presence again after the wait | the window. Not settable per project. A host applies at most one presence change a second, so a faster one shows the host nothing |
| `statsReportsPerMinuteSession`, 4 a minute | one session's stats reports | `rate_limited` | nothing; the report is refused, not applied | the window |
| `ticketMintsPerMinute`, 120 a minute per key | a ticket mint | `rate_limited` | nothing | the window |
| `tokensPerMinute`, 120 a minute per key | a token mint | `rate_limited` | nothing | the window. `tokensOutstanding`, 10000 live tokens, refuses the same way. The project's value is the ceiling each of its keys is held to, not a total across them |
| 120 lossy messages a second, per client, across all lossy channels on one end | nothing; the SDK drops the excess here rather than sending it late | `{ dropped: 'rate' }` on the send, never an error | a `rateLimited` `diagnostic` naming the channel | the next tick of the pace. See [Channels](/docs/guides/channels/) |
| 20 reliable messages a second, per sender, across all reliable channels on one end | nothing at the sender; the receiving end enforces it and drops the excess | `rate_limited`, from the receiving end at most once a second | nothing dropped locally and no `rateLimited` `diagnostic`; a host sees the reply on the session's `error` event, a client's SDK only logs it. A client that stays over the rate for ten seconds is closed with code 4100 and booted: the host gets `clientLeft` with `booted`, the client ends with `booted` when it tries to return, and the session carries on | sending under the rate. See [Channels](/docs/guides/channels/) |

## Refusals

Every refusal is a `BitskiffError`. Seven fields say everything you need. They are the SDK's
fields: an error body over HTTP is a narrower thing, so the last column says where the same fact
arrives there, for a backend that calls the REST routes itself.

| Field | What it is | On the wire |
|---|---|---|
| `code` | `concurrency_limit`, `rate_limited` or `quota_exceeded` | `code` |
| `retryable` | `true` for a concurrency or rate refusal, `false` for a cap. A cap's wait is a reset, not a retry | `retryable` |
| `clears` | `waiting` when waiting alone can make the same call succeed, `never` when something else has to change first. A different fact from `retryable`: a spent cap is `retryable: false` and `clears: 'waiting'`, because the period rolls | nothing; the SDK derives it from the code and from where the answer came from |
| `source` | where the refusal came from: `api`, this SDK with no call made (`sdk`), or the edge in front of api (`edge`) | nothing; the SDK sets it itself |
| `retryAfterMs` | how long to wait. The time left in the window for a rate limit, the time to the reset for a cap | `retryAfterMs`, and `Retry-After` in seconds |
| `limit` | which limit refused: the `LimitName` spelling. A usage cap (`quota_exceeded`) sets it only for a secret key: on a publishable key it is `undefined`, and so is `details.scope`, because anyone can read a page's errors. The type covers every limit the platform has, more than the tables above name, so give a `switch` over it a default. Both SDKs export it: `import type { LimitName } from '@bitskiff/host'` (or `'@bitskiff/client'`) | `details.limit`, the same spelling. There is no top-level `limit` on the wire |
| `details.scope` | where it was counted: `project`, `key`, `subject`, `session`, `client`, `ip`, `org` or `address`. `ip` is the address a join, an open or a host ticket came from; `address` is the console's own sign-in windows, which no SDK call meets; `org` is a ceiling the organization's plan holds across every project, or an invitation window | `details.scope` |

A cap also carries `resetAtUnixMs`, the instant its period rolls. Render that as a date. Do not
compute one from `retryAfterMs`, which was measured when the answer was written. `resetAtUnixMs` is
the SDK's spelling: on the wire the same instant is `details.resetAt`, and only a cap carries one --
a rate window has no reset and sends none.

Every value in a REST `details` object is a string, `limit`, `scope` and `resetAt` included, because
that is what the wire type is. The SDK's fields are typed.

Waiting does not clear every refusal. 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. The two sample-page
bounds clear only by pointing the project's `joinUrl` at a page of your own.

The `What clears it` column above says which of the three a limit is, and a host that loops forever reads it to pick a
wait.

```js
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
// `until` is Unix ms, or undefined when no reset is known.
const showAtCapacity = (until) =>
  console.log(until === undefined ? 'At capacity' : `At capacity until ${new Date(until)}`);

async function openWhenThereIsRoom() {
  try {
    const session = await bitskiff.open();
    console.log(session.joinCode.url);
  } catch (err) {
    if (!isBitskiffError(err)) throw err;
    switch (err.code) {
      case 'concurrency_limit':
        // A client's place frees on its own, so try the same call again after the wait it named.
        setTimeout(() => void openWhenThereIsRoom(), err.retryAfterMs ?? 10000);
        return;
      case 'quota_exceeded':
        // Not retryable: this one clears at the period roll or when somebody raises it.
        showAtCapacity(err.resetAtUnixMs);
        return;
      default:
        throw err;
    }
  }
}

await openWhenThereIsRoom();
```

```ts
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
const showAtCapacity = (until: number | undefined): void =>
  console.log(until === undefined ? 'At capacity' : `At capacity until ${new Date(until)}`);

async function openWhenThereIsRoom(): Promise<void> {
  try {
    const session = await bitskiff.open();
    console.log(session.joinCode.url);
  } catch (err) {
    if (!isBitskiffError(err)) throw err;
    switch (err.code) {
      case 'concurrency_limit':
        // A client's place frees on its own, so try the same call again after the wait it named.
        setTimeout(() => void openWhenThereIsRoom(), err.retryAfterMs ?? 10000);
        return;
      case 'quota_exceeded':
        // Not retryable: this one clears at the period roll or when somebody raises it.
        showAtCapacity(err.resetAtUnixMs);
        return;
      default:
        throw err;
    }
  }
}

await openWhenThereIsRoom();
```

Write a `default` arm. A host that only knows the two codes above goes quiet on the third.

A page shows the time and nothing else. On a publishable key a spent cap tells the page when to
come back, never which limit it was or by how much. A host on a secret key also reads `err.limit`,
`details.scope`, `details.value` and `details.ceiling`, and can name the limit to its operator.

## Warnings

You do not have to learn a cap exists by being refused by one.

Three counters fire a webhook: `clientMinutesPerDay`, `monthlySpendCap` and `turnBytesPerMonth`.
Each sends `limit.warning` at 80 percent and `limit.exceeded` at 100. Each of the two fires once
per limit, per scope, per period, so neither is a stream.

Every delivery carries the `limit`, its
`scope` and `scopeId`, the `value` the counter reached, the `ceiling` it is measured against,
`requestId`, the `orgId`, and `keyId` for a cap a key holds. Point a URL at your project to
receive them; see [Webhooks](/docs/guides/webhooks/).

The console's Limits panel shows every limit with its effective value, what this period has spent
and when that period resets. `GET /v1/project/limits` is the same numbers over the API, for a
project that would rather graph them itself.

## A live session's standing

A running host learns a limit as it nears, without waiting to be refused.

The `session.capacity` object is your project's standing against the limits its sessions spend. It
is `{ status, limits, at }`.

`status` is `ok`, `warning` or `exceeded`, the same three words the webhooks use. `limits` holds
one row per limit that is not `ok`. Each row carries the `limit`, the `scope` it bound at, the
`scopeId`, its own `status`, the `value`, the `ceiling` and the `resetAt`.

It is a list because a
project can sit at `warning` on one limit while another is already `exceeded`. The top-level
`status` is the worst row's.

The rows go to a host on a secret key. A host on a publishable key reads no row for a usage limit,
because anyone can read a page: its standing stays `ok` with an empty list, and it learns a spent
cap from the refusal's time.

The one row it can read is `samplePageSessions`, while the project
still points at the sample client page. To watch a public page's limits, use the console or the
webhooks above.

```js
import { BitskiffHost } from '@bitskiff/host';

const banner = document.getElementById('at-capacity');
if (!banner) throw new Error('missing element');

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();

session.on('capacity', (capacity) => {
  banner.hidden = capacity.status === 'ok';
  banner.textContent = capacity.status === 'exceeded' ? 'At capacity' : 'Nearly at capacity';
});
```

```ts
import { BitskiffHost, type CapacityNotice } from '@bitskiff/host';

const banner = document.getElementById('at-capacity');
if (!banner) throw new Error('missing element');

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();

session.on('capacity', (capacity: CapacityNotice) => {
  banner.hidden = capacity.status === 'ok';
  banner.textContent = capacity.status === 'exceeded' ? 'At capacity' : 'Nearly at capacity';
});
```

The standing rides the answers the host already makes -- the open response, and the traffic report
it posts every 60 seconds -- so a host learns inside a minute and nothing is pushed to it.
The `session.capacity` object is a snapshot replaced whole, and an `on('capacity')` listener fires
when the standing moves: a limit that stands for an hour is one event, and clearing is an event too.

A status of `exceeded` ends nothing. The session keeps its clients and its channels; only new opens and new
joins are refused. A host that paints "at capacity" from this reads better than one that goes dark
at its next refused `open`.

## Raises

Some limits a project raises on itself. The rest are fixed, and a raise naming one of those is a
`validation` refusal.

These are the raisable ones:

| Limit | What a raise buys |
|---|---|
| `concurrentSessions` | How many sessions the project may have open at once |
| `concurrentClients` | How many clients the project may have in sessions at once |
| `sessionOpensPerMinute` | How fast the project, and each of its keys, may open sessions |
| `joinsPerMinuteSession` | How fast clients may join one session |
| `joinsPerMinuteIp` | How fast clients may join from one address, to at most 200 |
| `joinsPerMinuteKey` | How fast clients may join with one client key, across its sessions |
| `clientResumesPerMinute` | How fast one client may resume or leave |
| `statsReportsPerMinuteSession` | How many traffic reports one session may post a minute |
| `linkMessagesPerSecond` | The lossy rate a channel may run at on a direct link |
| `bytesPerSecond` | The bytes one client may send a second, to the plan ceiling (16 KiB on experimental) |
| `clientMinutesPerDay` | The daily client-minute cap |
| `monthlySpendCap` | The monthly cap in rated units |
| `turnBytesPerMonth` | The monthly relay-byte cap |
| `ticketMintsPerMinute` | How fast one key may mint tickets |
| `tokensMaxTtl` | The longest a token may live, to `720h` (thirty days) |
| `hostTicketsPerMinuteIp` | How fast hosts behind one address may resume, to at most 300 |
| `sessionOpensPerMinuteIp` | How fast one address may open sessions, to at most 300 |

Raise one in the console's Limits panel, or with `POST /v1/project/limits/raise`. There are two
answers:

- Up to your organization's plan ceiling, the raise applies at once and takes effect on the next
  open or join. The answer is `applied: true`.
- Above the plan ceiling, the raise is held as a pending request and the limit keeps its value.
  The answer is `applied: false` with the plan's `ceiling`, and a `limits.raise_requested` event
  records it.

On the plan a new organization starts on, every raisable limit's ceiling is its default, except
the byte row, whose ceiling is 16 KiB. So a new project's first raise of any other limit comes
back `applied: false`. A raise naming a limit outside that table is refused `validation`, so write
the error branch.

`linkMessagesPerSecond` is the ceiling a lossy channel's `maxHz` is clamped to on a direct link.
It starts at the engine rate, 120 lossy messages a second (the lossy row under Rate limits), and
never sits below it: lowering it under 120 does nothing. Declare a `maxHz` above 120 and, once
raised, the channel runs at the lower of the two while a client's link is up.

A raise reaches a session's channels at its next `open()`. A channel reports the rate as `linkMaxHz` beside `maxHz`,
and runs at `maxHz` again when the link drops; a host broadcast runs at `linkMaxHz` only while
every client it reaches has one.

Lowering is always immediate and never ends live work. The count drains as sessions end.

`bootCooldown` is lowerable and not raisable, and its value is a duration. Lower it with
`bitskiff limits set bootCooldown 2m`, or turn it off with `0s`. `GET /v1/project/limits` reads it
back as a duration string, and `PUT /v1/project/limits` takes one. A value above `10m` is refused
`quota_above_ceiling`.

The two sample-page bounds are fixed as well. The sample client page is there so your first host
has something to be scanned into, and the way past `samplePageSessions` and
`samplePageJoinsPerDay` is to point the project's `joinUrl` at a page of your own.

## Per-key and per-subject limits

Every limit above is a project number, and a key or a token can carry a smaller one.

| Credential | The ceiling it carries |
|---|---|
| A publishable key shipped in a page | half the project's `concurrentSessions`, rounded down and never below 1, so it is 2 at the default 5 |
| A secret key handed to a partner | one of its own, as tight as you set it |
| A join token | one bound to a subject, so one signed-in person holds one session and one client however many tabs they open |

The chain runs project,
then key, then token, then the request, and the most restrictive value wins. A key may only tighten
what the project set, and a request to loosen it is refused `quota_above_ceiling` rather than
quietly clamped. The `details.scope` key on that refusal says which level bound it.
[Credentials](/docs/guides/credentials/) has what each key, token and subject is allowed to do.

One limit is a word, not a number: whether a join must carry a join token.

| Limit | Default | Values, least strict first | How to set it |
|---|---|---|---|
| `requireToken` | `off` | `off`, `token`, `token_bound_to_session` | `bitskiff limits set requireToken token`, or add `--key key_...` for one key. `bitskiff keys create` and `keys update` take `--limit requireToken=token` |

On `POST /v1/project/limits/raise` the word goes in `word`, not `value`:
`{ "limit": "require_token", "word": "token" }`, with `keyId` for one key. Any other word, or a
number, is refused `validation`, and the message lists the three words.

`token` refuses every join that carries no join token holding `sessions:join`. The refusal is
`forbidden` with `details.reason` of `token_required`. `token_bound_to_session` also refuses a
token that does not name this session. A key may require more than the project and never less, and
what a key requires reaches that key's sessions only.

## Abuse

Channels carry your data. The control plane, the part of a session that says who is connected and
how, is not a second way to send it. It is metered and bounded:

- Every control frame a client sends, such as a hello, a ping or a presence change, is counted by
  kind. The counts show on the session's meters in the console. They are not billed.
- A host applies at most one presence change per client per second. A client that flips between
  away and back faster than that shows its latest state once a second.
- A hello is at most 8 KiB and a ping at most 256 bytes. A larger one is refused with
  `payload_too_large`.
- Channel names are sealed when the host opens the session: at most 64 names, each at most 64
  bytes.
- Session metadata is set through the API only. A client cannot write it.

The per-second and byte figures are starting values and may change.

A project that uses the control plane to carry data is a terms matter, not a billing one.

## Next steps

- [Webhooks](/docs/guides/webhooks/)
- [Channels](/docs/guides/channels/)
- [Session lifecycle](/docs/guides/lifecycle/)
- [Unattended hosts](/docs/guides/unattended-hosts/), for a host that reopens by itself
  after a session ends
