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, 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:
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 asNumber(record.seatMs ?? 0). - A zero is left out, and so is an empty
subject. msByBundleis a client's time under each bundle (controller,viewerorcustom), joining time included, so it adds up to at leastseatMs.
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.
| 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
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:
{
"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
SessionUsageandClientUsage.