After every voice session your backend can learn what it consumed — how many minutes, for which of your users, and how it ended — two ways: pull (a sessions API you call when you like) and push (a signed webhook we send the moment a session ends). Both ship on every plan, Free included — you wire and test the integration before you pay. You are billed in minutes, so you get minutes: no tokens, no provider names, no per-minute cost.
The session id
The mint response now carries a sessionId — an opaque ses_… handle. Store it against your user; it is the one identifier for the session everywhere you can see it (the mint response, the record, the API, the webhook).
{ token, serverUrl, cloudUrl, language, supportedLanguages,
sessionId: "ses_01J9…", // store this
session: { budgetMinutes: 7, closing: "cut", region: "europe" } } // what actually appliedThe record
Both the pull API and the push webhook return the same object:
{ sessionId, userId, startedAt, endedAt, billedMinutes, turns,
endReason: "complete" | "user_away" | "out_of_credit"
| "session_limit" | "daily_limit" | "budget_limit" | "error",
region, status: "recorded" | "unrecorded",
session: { budgetMinutes?, closing?, region? } }billedMinutes is null (never 0) for a session that appears in the transport log but never wrote a record (status: "unrecorded").
Pull: the sessions API
Server-to-server, on the same secret x-api-key as /session (never from the browser). Fetch one session, or list them:
GET {base}/sessions/{sessionId} → the record above
GET {base}/sessions?userId=&from=&to=&cursor=&limit=
→ { sessions: [ …records… ], nextCursor: "…" | null }
newest first · limit 1–100 (default 50) · from/to are ISO-8601 instants on startedAtPush: webhooks
Configure one endpoint under Settings → Webhooks (every plan). We POST a signed event the moment a session ends (session.ended, data = the record) and when your balance runs low (credits.low). Failed deliveries retry with backoff (1 min, 5 min, 30 min, 2 h, 12 h); five consecutive failed events auto-disable the endpoint (we email you, re-enable in the portal). Verify the signature with verifyWebhook — read the raw body:
import { verifyWebhook } from "@coworkkit/server";
export async function POST(req: Request) {
const raw = await req.text(); // the RAW bytes — a re-serialised body won't match
const event = verifyWebhook(raw, req.headers.get("coworkkit-signature"), process.env.COWORKKIT_WEBHOOK_SECRET!);
if (event.type === "session.ended") {
// idempotent on sessionId — deliveries are at-least-once
await db.deductMinutes(event.data.userId, event.data.billedMinutes, event.data.sessionId);
}
return new Response("ok");
}Quotas — keep one user from draining your balance
A quota is a standing per-user wall; a budget is what your code hands one session. Three depths, one per tier:
- Fixed (Free) — 30 min per session, 60 min per user per day. We protect; you can't change them.
- Adjustable (Starter) — tune both walls (or switch them off) in the coworker's Quotas card. One allowance for every user.
- Programmable (Growth) — pass
session.budgetMinutesat mint from your own code, so different users get different allowances.
The layers stack; the tightest wins. When a wall is hit the user hears a short goodbye (or a clean cut, per session.closing) — never "out of credit"; the record's endReason says which wall it was, and a refused mint returns reason: "daily_limit".
The session container echoes what applied
Read the response's session to know what will actually govern the session: budgetMinutes is the ceiling that will bind (absent when unbounded), closing is "goodbye" (default) or "cut", and region is the basin honoured (present only when a preference was). On Free and Starter the plan's wall is echoed whatever you requested — nothing is refused, the response just tells you what applies.