Bitskiff

Usage-based billing

On this page

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

Monthly totals

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

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

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:

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

SliceRecordPlaced byBill on
sessionSessionUsage, one per ended sessionendedAtattachedMs, the time a host was attached
clientClientUsage, one per client, when it leaves or its session endsgoneAtseatMs, the client's time in the session
daySessionUsage, one per session per UTC day it was opendayattachedMs, 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 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.

RefusalWhenWhat to do
validation with details.field toThe range is over 31 days, or would scan more than one read allowsRead the period in parts, such as two halves of the month
rate_limitedPast 30 reads a minute for the project, or 200 new reads a day per key (details.limit usageKeyDailyRangeReads)Wait retryAfterMs
unavailableOther usage reads of the project are queued, or the ledger cannot be readWait 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 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 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 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.

Next steps

  • Limits: every limit a token's subject carries.
  • Webhooks: the same records as each session or client ends.
  • REST reference: every field of SessionUsage and ClientUsage.