# Usage-based billing

Bill your own customers on what their sessions used, from the usage records Bitskiff keeps.

## Monthly totals

Once a month is [final](#final-records), read its records from the ledger and add them up per subject. Day records
give host time and client records give client time:

```js
// Your backend. Each subject's host and client time in one UTC month, in ms.
const endpoint = process.env.BITSKIFF_ENDPOINT;   // your endpoint, from `bitskiff whoami`
const key = process.env.BITSKIFF_SECRET_KEY;      // bsk_sk_..., holding usage:read

async function readAll(slice, from, to) {         // every record of a slice in [from, to)
  const records = [];
  let cursor = '';
  do {
    const query = new URLSearchParams({ slice, from: String(from), to: String(to), limit: '200' });
    if (cursor) query.set('cursor', cursor);
    const response = await fetch(`${endpoint}/v1/project/ledger?${query}`, {
      headers: { authorization: `Bearer ${key}` },
    });
    const page = await response.json();
    if (!response.ok) throw new Error(`ledger read refused: ${page.code}`);
    if (Number(page.frozenThrough ?? 0) < to) throw new Error('not final yet: read again later');
    records.push(...(page.sessions ?? []), ...(page.clients ?? []));
    cursor = page.nextCursor ?? '';
  } while (cursor);
  return records;
}

export async function monthTotals(year, month) {  // month is 1 to 12
  const from = Date.UTC(year, month - 1, 1);
  const to = Date.UTC(year, month, 1);
  const days = await readAll('day', from, to);
  const clients = await readAll('client', from, to);
  const opener = new Map(days.map((d) => [d.sessionId, d.subject ?? '']));
  const totals = new Map();                       // subject -> { hostMs, clientMs }
  const add = (subject, field, ms) => {
    const row = totals.get(subject) ?? { hostMs: 0, clientMs: 0 };
    row[field] += Number(ms ?? 0);
    totals.set(subject, row);
  };
  for (const d of days) add(d.subject ?? '', 'hostMs', d.attachedMs);
  // A client with no subject of its own is billed to the subject that opened its session.
  for (const c of clients) add(c.subject || opener.get(c.sessionId) || '', 'clientMs', c.seatMs);
  return totals;
}
```

```ts
// Your backend. Each subject's host and client time in one UTC month, in ms.
const endpoint = process.env.BITSKIFF_ENDPOINT;   // your endpoint, from `bitskiff whoami`
const key = process.env.BITSKIFF_SECRET_KEY;      // bsk_sk_..., holding usage:read

interface LedgerRecord {
  sessionId: string;
  subject?: string;                               // left out when empty
  attachedMs?: string;                            // a 64-bit number arrives as a string
  seatMs?: string;
}

interface LedgerPage {
  sessions?: LedgerRecord[];
  clients?: LedgerRecord[];
  nextCursor?: string;
  frozenThrough?: string;
  code?: string;
}

export interface SubjectTotals {
  hostMs: number;
  clientMs: number;
}

async function readAll(slice: string, from: number, to: number): Promise<LedgerRecord[]> {
  const records: LedgerRecord[] = [];
  let cursor = '';
  do {
    const query = new URLSearchParams({ slice, from: String(from), to: String(to), limit: '200' });
    if (cursor) query.set('cursor', cursor);
    const response = await fetch(`${endpoint}/v1/project/ledger?${query}`, {
      headers: { authorization: `Bearer ${key}` },
    });
    const page = (await response.json()) as LedgerPage;
    if (!response.ok) throw new Error(`ledger read refused: ${page.code}`);
    if (Number(page.frozenThrough ?? 0) < to) throw new Error('not final yet: read again later');
    records.push(...(page.sessions ?? []), ...(page.clients ?? []));
    cursor = page.nextCursor ?? '';
  } while (cursor);
  return records;
}

export async function monthTotals(year: number, month: number): Promise<Map<string, SubjectTotals>> {
  const from = Date.UTC(year, month - 1, 1);
  const to = Date.UTC(year, month, 1);
  const days = await readAll('day', from, to);
  const clients = await readAll('client', from, to);
  const opener = new Map(days.map((d) => [d.sessionId, d.subject ?? '']));
  const totals = new Map<string, SubjectTotals>();
  const add = (subject: string, field: keyof SubjectTotals, ms: string | undefined) => {
    const row = totals.get(subject) ?? { hostMs: 0, clientMs: 0 };
    row[field] += Number(ms ?? 0);
    totals.set(subject, row);
  };
  for (const d of days) add(d.subject ?? '', 'hostMs', d.attachedMs);
  // A client with no subject of its own is billed to the subject that opened its session.
  for (const c of clients) add(c.subject || opener.get(c.sessionId) || '', 'clientMs', c.seatMs);
  return totals;
}
```

Call `monthTotals(2026, 9)` on or after October 4 for September. Divide each total by 60,000 for
minutes, round the way your price says, and send each subject's two numbers to your billing system.
The subject `''` is time no subject pays for: sessions opened with a key, and their clients.

## The ledger

`GET /v1/project/ledger` takes a secret key that holds `usage:read`:

```
GET /v1/project/ledger?slice=day&from=1788220800000&to=1788307200000&limit=200
Authorization: Bearer bsk_sk_...
```

`from` and `to` are Unix ms, and the range is half-open: this one is September 1, 2026, UTC. Records
come under `sessions` (the session and day slices) or `clients`, ordered by `endedAt`, `day` or
`goneAt`.

A page holds up to `limit` records, 1 to 200, 50 by default. Send `nextCursor` back as
`cursor` until a page has none; it lasts 24 hours. Add `subject` to read one subject's records, and
`format=csv` for CSV.

To read it by hand, run `bitskiff ledger --slice day --month 2026-09`. It also takes `--subject`,
`--csv` and `--all` to follow every page.

| Slice | Record | Placed by | Bill on |
|---|---|---|---|
| `session` | `SessionUsage`, one per ended session | `endedAt` | `attachedMs`, the time a host was attached |
| `client` | `ClientUsage`, one per client, when it leaves or its session ends | `goneAt` | `seatMs`, the client's time in the session |
| `day` | `SessionUsage`, one per session per UTC day it was open | `day` | `attachedMs`, that day's share only |

A session open for weeks has one session record, on the day it ends, and a day record for each day
it was open. Monthly totals bill host time from day records, so each month bills the days in it.
Bill host time from one of the two, never both.

Three things to know when you read a record:

- **Durations are strings**, such as `"3000000"`: read each as `Number(record.seatMs ?? 0)`.
- **A zero is left out**, and so is an empty `subject`.
- **`msByBundle`** is a client's time under each bundle (`controller`, `viewer` or `custom`),
  joining time included, so it adds up to at least `seatMs`.

The [REST reference](/docs/reference/rest/) lists every field.

## Final records

Every page carries `frozenThrough`, Unix ms as a string. A record before it no longer changes, so
bill a month once `frozenThrough` has passed its end. Before that, a late end can still merge in.

A day is final at 00:00 UTC on the fourth day after it starts, in all three slices: September 1 on
September 5, and all of September on October 4.

A correction after that is an incident Bitskiff runs, and every record it touches carries a newer
`writtenAt`. The ledger keeps records for 400 days.

## Ledger errors

Only a first page starts a read. A later page can fail only with `unavailable`, `rate_limited`, or
`validation` on a `cursor` older than 24 hours.

| Refusal | When | What to do |
|---|---|---|
| `validation` with `details.field` `to` | The range is over 31 days, or would scan more than one read allows | Read the period in parts, such as two halves of the month |
| `rate_limited` | Past 30 reads a minute for the project, or 200 new reads a day per key (`details.limit` `usageKeyDailyRangeReads`) | Wait `retryAfterMs` |
| `unavailable` | Other usage reads of the project are queued, or the ledger cannot be read | Wait `retryAfterMs` |

## Subjects and limits

A subject is your own id for whoever a token acts for, and how you split the bill is yours.
Each record carries the subject its credential named:

- A session or day record: the subject of its host's token.
- A client record: the subject of the client's own token, none for a client key.
- A session opened with a key, as the quickstart's host is: none.

The totals above bill a client to its own subject, else to its session's. For the venue to pay for
every client, use `opener.get(c.sessionId) || ''` for every client instead.

A ledger record's subject is always known. A `client.gone` [webhook](/docs/guides/webhooks/#usage-records)
with `subjectUnknown` true carries an empty subject that is unknown, not absent, so bill from the
ledger.

Make the subject the party you bill, such as a venue's account: an id of 1 to 128 letters, digits,
marks or ASCII punctuation, never a name or an email address, which would show in every record.

### Hosts you bill

A host you bill opens with a [token](/docs/guides/credentials/#tokens) that carries
`sessions:open` and the venue's subject, passed where a key goes. A subject holds one session at a
time and opens three a minute by default, so mint a venue with several hosts a token that says so:

```json
{
  "subject": "venue_17",
  "permissions": ["sessions:open"],
  "limits": { "concurrentSessions": 4, "sessionOpensPerMinute": 4 }
}
```

Without `concurrentSessions`, the venue's second host is refused `concurrency_limit` with
`details.scope` `subject`. A value above your key's is refused `quota_above_ceiling`.

Prefer a host that fetches a fresh token from your backend: pass a function as the key, such as
`key: async () => fetchTokenFromYourBackend()`. The SDK calls it on every open, so a `10m` token
is enough.

A device that must reopen without reaching your backend needs a long token: raise the project's
`tokensMaxTtl`, up to `720h`, and store the token on the device. Whoever copies it opens sessions as
the venue until it expires or you delete it.

## Stripe Billing Meters

For invoices that follow usage within days, the Stripe billing sample on the
[downloads page](/docs/downloads/) is an hourly job that sends each final day to Stripe Billing
Meters and checks that Stripe counted it. It suits an account already on Billing Meters. For a new
integration, Stripe recommends Metronome: see Stripe's
[usage-based billing guide](https://docs.stripe.com/billing/usage-based).

## Next steps

- [Limits](/docs/guides/limits/): every limit a token's subject carries.
- [Webhooks](/docs/guides/webhooks/): the same records as each session or client ends.
- [REST reference](/docs/reference/rest/): every field of `SessionUsage` and `ClientUsage`.
