Your server — WitbitzAdmin
What belongs to your tenant rather than to a room — its keys, the origins your pages run on, your users and what they
cost, deleting rooms — is done from your server with WitbitzAdmin, never from a page.
Part of the SDK · every call: the reference.
Set it up
Most of the SDK is a member's work and needs no key: a room proves itself by its key, so the room endpoint asks for
nothing else. A few calls are the builder's — your OAuth apps, the hosts your users may attach a key for, the
origins your pages run on, deleting rooms, billing — and those belong to your tenant, proved by your tenant secret
key (wsk_…). They live in their own module, for your server: a page holding the key has given it away, and a page
that imports only witbitz-sdk.js cannot hold it by accident.
Download it once, as one file, beside your server's code (Node cannot import from a URL):
curl -O https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-admin.mjs # a released version; Node 18+, no packagesimport { WitbitzAdmin } from './witbitz-admin.mjs'
const admin = new WitbitzAdmin({ tenant: 't_…', tenantKey: process.env.WITBITZ_TENANT_KEY })
const { ok, tenant, reason } = await admin.verifyTenantKey() // is this key good, and whose is it?- Two endpoints.
WitbitzAdminsends its calls to the authenticated endpoint (/v1/tenant-space) and the page SDK sends member calls to the keyless one (/v1/space). The first is derived from the second, so a staging or self-hosted deployment needs no configuration;new WitbitzAdmin({ endpoint, tenantEndpoint })overrides both. 403 tenant_required? Read thereason. It tells apart two faults that look the same from outside: a request that reached the keyless endpoint, where your key was never examined — a routing mistake, not a bad secret — and one that authenticated but carried a credential naming no tenant (a session or a publishable key instead of awsk_).verifyTenantKey()settles which you have.- Older names. The page client's builder calls (
wb.origins,wb.users,wb.billing, …, withtenantKeyonnew Witbitz) still reach the same calls; a page that passestenantKeygets a console warning.admin.customersis whatwb.userswas: your app's billed users, not Witbitz accounts.
Restricted keys
Your secret key can do everything, including deleting every room and the tenant itself. A server that needs only part
of that — an account server that creates customers and mints their tokens — should hold a restricted key (wrk_…)
with only the capabilities you choose. Mint one with your secret key:
const { key, keyId } = await admin.keys.create({
type: 'restricted', label: 'account server',
scopes: ['customers:write', 'customers:tokens'],
maxCustomerCapUsd: 50, // optional: the highest monthly cap this key may give a customer
expiresAt: '2027-10-01', // optional: refused from then on; at most two years ahead
})
const accounts = new WitbitzAdmin({ tenant: 't_…', tenantKey: key }) // the same client, fewer powerskey is shown once: store it as that server's secret, and keep the wsk_ key offline.
| Capability | What it opens |
|---|---|
customers:read |
admin.customers.list() |
customers:write |
customers.create, customers.update, customers.revoke |
customers:tokens |
customers.token(userId) — a customer's turn token |
customers:erase |
customers.erase({ email }) — deletes the rooms that person created |
rooms:read · rooms:write · rooms:delete |
your room counts · refreshing and restoring rooms · deleting one room |
settings:read · settings:write |
your tenant record · the webhook URL, requireUserToken, visitorLimit, your app's name, provider keys |
origins:write |
admin.origins.declare — which pages act as your app |
integrations:write |
admin.providers and admin.hosts |
billing:read · wallets:grant |
admin.billing · granting credit |
apps:release |
releasing your app through /v1/apps (witbitz app release): reserve, submit and read a release — not claiming a new app id |
- Never grantable: managing keys (minting, listing, revoking), deleting the tenant, deleting every room.
- A restricted key cannot make itself or another key more powerful. To change what a key may do, mint another and
revoke the old one (
admin.keys.revoke(keyId));admin.keys.list()lists them. - Any key may call
verifyTenantKey(). A call the key cannot make answers403, naming the capability it lacks. A cap above the key's ceiling answers403 cap_above_ceiling, and a customer created with no cap gets the default lowered to the ceiling.
Signing your tenant's requests
Your tenant key proves who you are to Witbitz's gateway, and the Sentinel — the enclave that holds your OAuth apps' secrets, your users' connections and their erase lists — would otherwise take the gateway's word for it. Give your server a signing key and the Sentinel checks you itself: every request your server sends it is signed, and once the key is registered, an unsigned or altered request for your tenant is refused, whoever sends it, Witbitz's own servers included.
const { privateKey, publicKey } = await WitbitzAdmin.generateSigningKey() // once; keep privateKey with your tenant key
const admin = new WitbitzAdmin({ tenant: 't_…', tenantKey: process.env.WITBITZ_TENANT_KEY, signingKey: privateKey })
await admin.signing.register() // from now on, every Sentinel request of this tenant must be signed- From then on, pass
signingKeywherever you make aWitbitzAdmin; nothing else changes. - A new key only with the current one. The first key is registered on your tenant key alone; a new one only by a
request signed with the current key (
new WitbitzAdmin({ …, signingKey: oldKey }).signing.register(newPublicKey)). So a stolen tenant key alone can neither act in the Sentinel nor swap your signing key. - Your pages, signed too. With a signing key,
admin.origins.declare([...])also files the same list, signed, with the Sentinel (or calladmin.signing.publishOrigins()). From then on a page that names your tenant there — a person connecting through your OAuth apps, a device filing a person's erase list for you — must be on one of those origins. The Sentinel reads the page's origin from the person's own sign-in, so another app's page cannot spend your OAuth apps or file lists for you. If Witbitz's own app should be able to name your tenant, list one of its origins. - Watch the record. The Sentinel keeps your registered key in its sealed store on a Witbitz host. No one can forge
or change that record, but someone with access to the host could delete it, and a tenant with no key registered
is again taken on the gateway's word.
await admin.signing.status()→{ registered, key, origins }: alert if it ever readsregistered: falseafter you registered. - Lost your signing key? Write to us: we remove the record, with your tenant key and proof of one of your origins, and you register a new one.
Your page origins
The origins your pages run on act for your tenant: a room created from one of them is yours, and your rooms can be read
and embedded from them. A page on an origin you have not declared gets 403 tenant_required when it creates a room.
await admin.origins.declare([
'https://app.example.com',
'https://test.example.com',
'https://old-box.example.ts.net', // declaring replaces the list: keep every origin you still use
]) // → { origins, unproven }
await admin.origins.list()- Compared by exact string against the
Originheader the browser sends, so the server stores that exact form:https://hostorhttps://host:port. It lower-cases and drops a trailing slash for you, and refuses anything else —http://, a path, a wildcard,localhost, an IP — naming the entry, rather than store something that would admit nobody. At most 20. A change takes effect within about a minute. - Over HTTP it is
PUT /v1/tenant/webhookswith{ "origins": [...] }and your key;GET /v1/tenantreads it back. - Nothing else of ours is tied to your origin: sign-in codes, connections,
admin.hosts, the Siri inbox and the API's CORS work from any origin. What moves with you is on your side: your own OAuth app's redirect URI and JavaScript origins (and theredirectUriyou registered withadmin.providers).
Prove the origin is yours
Declaring an origin is enough for your pages to work. For your app's name to appear in sign-in mails sent from it, the origin's host must say it belongs to your tenant, one of two ways:
- a file at
https://<your origin>/.well-known/witbitz-tenant.txtwith a line holding your tenant id (t_…), or - a DNS TXT record
_witbitz.<host>with the valuewitbitz-tenant=t_….
We check when you declare: declare answers each origin it could not prove in unproven, with the exact file or
record to add — add it and declare the same list again. GET /v1/tenant lists the proven ones in origins_proven.
Without this, anyone could declare a site that is not theirs and have Witbitz mail its users a code in that site's name.
Your app's name
A sign-in code mailed from one of your declared and proven origins comes from "
await admin.app.rename('Keepwarden') // → { name: 'Keepwarden' }
await admin.app.name() // → 'Keepwarden'- 1–40 characters: letters, digits, spaces and
. & ' + -. A name containing "Witbitz" is refused. - A rename reaches the mail within about 15 minutes.
- A page on an origin you have not declared or proven, or a sign-in that names no tenant, gets the plain Witbitz mail.
The page sends your tenant when you make its client with
new Witbitz({ tenant }).
Hosts and OAuth apps
await admin.hosts.declare(['api.example.com']) // the hosts your users may attach a key for; replaces the list
await admin.hosts.list()
await admin.providers.attach({ kind: 'google', clientId, clientSecret, redirectUri, scopes, label }) // → { id, kind, tenant }
await admin.providers.attachMcp({ issuer, clientId, clientSecret, redirectUris, label })
await admin.providers.list()
await admin.providers.revoke(id) // → { revoked, connections }The client secret of an OAuth app you attach is sealed to the Sentinel. Your users then connect through it from the
page — connections. attachMcp files an OAuth app for an MCP server that does not let apps
register themselves (GitHub, Asana, Box).
Billing, per user
Your rooms' model use is billed to you, in the provider's own numbers per key — never our token arithmetic. To see, cap and pass on what each of your users costs, give each one their own key: their turns then bill to it, and what they cost is what the provider reports for that key.
// on your server, once per user
const user = await admin.customers.create({ userRef: 'cust-42', capMonthlyUsd: 10 }) // → { userId: 'u_…', … }
// on your server, when that user signs in, and again before it expires
const { token, expiresAt } = await admin.customers.token(user.userId, { ttlSec: 900 })// in their page
const wb = new Witbitz({ tenant: 't_…', userToken: token })
wb.useUserToken(async () => (await fetch('/api/witbitz-token')).text()) // or a function that fetches a fresh one- Each user key has a hard monthly cap (
capMonthlyUsd, default 25). When it is spent, that user's turns are refused by the provider until the month resets; nobody else is affected.userRefis your label, shown to you in billing and never sent to the provider, which sees an opaque key name. - The token is signed by Witbitz and lasts
ttlSec(60–3600 s, default 900). It goes only with operations that can spend — a turn, a title, approving an action, an import — so a page left open past its expiry can still read its rooms. - A token that does not hold is refused, never billed to you:
401 user_token_invalid(bad or expired — mint a new one),402 user_key_unavailable(revoked). Without a token, a turn bills to your tenant key. - The person prompting pays, in any room. A token bills its user's key wherever that user prompts — your rooms, another tenant's, or a room with no tenant.
- Managing them:
admin.customers.list()(nothing secret in it);update(userId, { capMonthlyUsd: 40 })for a plan change — the provider's limit moves first, and the user, their key and their history stay;revoke(userId)deletes the key, and spend up to that moment is still billed. A revoked user cannot be changed. - Requiring the token. Without one, a spending operation bills to your tenant key — so on a public page anyone
could skip your per-user caps by sending none. Turn that off once, from your server:
await admin.billing.requireUserToken(true). From then on a turn, title, approval or import in your rooms that arrives without a user token is refused (401 user_token_required) instead of billed to you; reads never need one. It takes effect within about a minute, andrequireUserToken(false)undoes it. If Witbitz cannot read this setting at that moment and has no recent answer, a spending operation in your rooms is refused (503 billing_policy_unavailable) rather than billed to you on a guess — retry shortly. - Confidential rooms bill the same way. The token travels sealed to the attested enclave, which checks it and fetches that user's key, also sealed, so no key is usable outside the enclave.
People who are not signed in
A public page — a help desk, a demo, a support chat — has visitors with no account and so no user token. Their questions bill to your tenant key. To keep that bounded, set how many questions a day they may ask:
await admin.billing.visitorLimit({ perNetwork: 20, total: 2000 }) // null turns it off- What is counted: a question (a turn) in your rooms that carries no user token, when the assistant will answer it. A message the assistant does not answer (an on-demand room, not addressed to it) is not counted, and a question resent while the room is busy counts once. A question with a user token is that user's, under their own cap.
- Per network, per UTC day: at most
perNetworkquestions from one network (an IPv4 address, or an IPv6 /64), and at mosttotalfrom all of them. The address itself is never stored or sent: the Space's front door signs a daily hash of it, salted with your tenant, so it cannot be matched across apps. - Over a limit, the question is refused before anything runs or is stored:
send()answers{ ok: false, status: 429, error: 'visitor_limit', scope: 'network' | 'total', reason }—reasonis a sentence to show the visitor. If the count cannot be had, the question is refused (503 visitor_count_unavailable), never answered for free; retry shortly. - Both tiers, standard and confidential: in a confidential room the count is asked for by the attested enclave, at the moment it knows the question bills to you.
- Takes effect within about a minute. Over HTTP:
PUT /v1/tenant/webhooks{ "visitorLimit": { "perNetwork": 20, "total": 2000 } }with your secret key (or a restricted key withsettings:write).
What you are billed, from your server — the same numbers as the tenant portal:
const b = await admin.billing.get() // { month, days, users, userDays, storage, rate, … }, in microdollars
const csv = await admin.billing.csv({ byUser: true }) // one line per user per day; without it, one line per daymonth is your own key, live, plus your users' keys through yesterday; users lists each with its cap and this
month's spend. Over HTTP: POST|GET /v1/tenant/users, PATCH /v1/tenant/users/{id} { "capMonthlyUsd": … },
DELETE /v1/tenant/users/{id}, POST /v1/tenant/users/{id}/token { "ttlSec": … }, PUT /v1/tenant/webhooks
{ "requireUserToken": true }, and GET /v1/billing (?format=csv, &by=user), each with your secret key.
Knowing who is signed in, on your server
Before your server mints a user token, it should know the caller really is the Witbitz account it claims. A signed-in
page holds wb.session = { email, idToken, exp, sign }, and idToken is a standard JWT your server can verify on
its own:
alg: RS256. The key is in the issuer's JWKS — for the SDK's default issuer,iss: "https://witbitz-spaces.pages.dev", athttps://witbitz-spaces.pages.dev/api/email/jwks(select bykid; cache it, and fetch again on an unknownkid).- Check
aud === "witbitz-email",expin the future andemail_verified === true; the account isemail. - The token lasts 30 days, and every Witbitz app shares its audience, so a token alone proves the account but not
that this page is presenting it. Make the page prove it holds the session's key: your server sends a fresh random
challenge; the page signs it with
wb.session.sign.priv(ECDSA P-256, SHA-256) and returns{ idToken, spk: wb.session.sign.pub, sig }. Your server checks the signature againstspk, and that the token'snonceequals standard base64 (with padding) of SHA-256 over the canonical JSON ofspkexactly as the page sent it — keys sorted at every level, no spaces, such as{"crv":"P-256","kty":"EC","x":"…","y":"…"}— so the token is bound to that key. A copied token cannot pass without the key.
Deleting a room
When a person deletes their data, delete their rooms from your server. You choose how much to keep, and for how long:
await admin.rooms.delete('sp-…') // retain: 'none' — everything, now
await admin.rooms.delete('sp-…', { retain: 'backups' }) // now, except old versions for 35 days
await admin.rooms.delete('sp-…', { retain: 'grace', graceDays: 30 }) // nothing yet; undo until the window ends
await admin.rooms.restore('sp-…') // → { restored: true, room }retain |
The room | Its data | Undo |
|---|---|---|---|
'none' (default) |
gone at once | every stored version deleted now: sealed messages, files, the confidential tier's copies | none |
'backups' |
gone at once | current copies deleted now; old versions expire within 35 days | only by Witbitz operators, by hand, until then |
'grace' |
gone at once | nothing deleted until the window ends (graceDays, 1–90, default 30), then purged as after says ('none' by default, or 'backups') |
admin.rooms.restore(room) until the window ends |
"Gone at once" holds in every mode: from the moment you call, the room answers 410 deleted to everyone, a member's
open device included, and no one can make a room with the same id again. A member's space.send returns
{ status: 410, error: 'deleted' }.
- Only for a room no one else uses. A shared room belongs to its other members too. Our server cannot tell a room with one person from a shared one, because membership is sealed, so that decision is yours. When a person leaves a shared room, forget it on their side instead.
- What it answers:
{ deleted: true, room, retain, versions, bytes, turnWasRunning }(plusbackupsUntilwith'backups'), or{ deleted: false, held: true, room, purgeAt, after }with'grace'.restoreanswerspurgedorgrace_endedonce it is too late. turnWasRunning: truemeans a reply was being written at that moment and may land a few seconds later. Calldeleteagain after a few minutes; it is idempotent and sweeps up whatever arrived.- It throws if anything could not be removed (
partial). Call it again; it is never reported done when it is not. - A held room can be purged early: call
deleteagain with'none'or'backups'. - What no option deletes on request: our database keeps a 35-day point-in-time recovery of room records, which hold no messages, only encrypted settings, and expire by themselves. The model providers that answered a room's turns keep their own logs under their own terms.
- Over HTTP:
DELETE /v1/tenant/rooms/{room}with your key and{ "confirm": "<room>", "retain": …, "graceDays": …, "after": … };POST /v1/tenant/rooms/{room}/restoreto undo.DELETE /v1/tenant/rooms(with{ "confirm": "<tenant id>" }and the same options) does it for every room you own.
Your rooms, and the idle rule
admin.rooms.list() returns your rooms a page at a time — to find a room whose link was lost, or to audit what you
hold:
let cursor
do {
const { rooms, next } = await admin.rooms.list({ cursor }) // rooms: [{ room, createdAt, lastUsedAt, kind, split, gated, held }]
cursor = next
} while (cursor)createdAt and lastUsedAt are ISO times or null. lastUsedAt is when a member last opened the room, wrote to it
or prompted in it (recorded since 2026-10-07; your own rooms/touch does not count). held is { purgeAt } for a
room waiting to be deleted. Rooms made before creation times were recorded show createdAt: null.
The idle rule deletes rooms nobody uses, on a schedule you set:
await admin.rooms.idleDeletion(180) // rooms no member used for 180 days: held, then deleted
await admin.rooms.idleDeletion(null) // offOnce a day, each room past the limit is put on a 30-day hold — it answers 410 at once, and
admin.rooms.restore(room) brings it back until the hold ends — and is then deleted (retain: 'grace' above). The
limit is 30 to 3,650 days. A room with no recorded use counts from 2026-10-07, so turning the rule on deletes nothing
that day. At most 100 of your rooms are held a day. Like delete, it cannot know who else uses a room — but a room past
the limit is one nobody has opened.
Erasing a user who has left
When someone deletes their account in your app, you want their rooms gone too. You cannot simply delete "their rooms" from your server: you have no list, membership is sealed, and a room they shared belongs to the others in it. Erasure works from a list the person's own devices filed while they used your app, so neither you nor Witbitz holds it before:
// on your server, with your tenant key — after the person asked to be erased
const r = await admin.customers.erase({ email: 'dana@example.com' })
// → { filed, rooms, deleted, shared, notTheirs, failed, cleared, incomplete? }
if (r.failed > 0 || r.incomplete) { /* run it again later; what was deleted stays deleted */ }
await admin.customers.revoke(userId) // their billing key, if they had one- What is deleted: the rooms that person created in your app and nobody else used — every message, file and stored
version, as
admin.rooms.deletewithretain: 'none'. - What stays: a room they created that someone else used is refused as
shared, because what they wrote there is part of the others' conversation; a room they only joined is counted asnotTheirs, because its creator decides.
How it holds. Three things in the page make it safe, and wb.erasure does all three for you:
- A deletion commitment. Every room the SDK creates records a hash of a delete secret, derived from the account's backup master secret and the room id — so only that account can produce it, on any of its devices. A person who joined holds the room key but not the secret, so cannot delete the room.
- A membership mark. Every room the SDK opens is marked with an opaque tag for the account. A room that carries
anyone else's mark is refused as
shared, even when its creator asks. - The filed list. A few seconds after a room is created or opened, the account's list of rooms and delete secrets
is sealed to Witbitz's attested enclave under your tenant and the person's email, each filing replacing the last.
It is opened only when your server calls
erase, and cleared once nothing failed.
What the page needs: new Witbitz({ tenant }), a signed-in person (wb.signIn) and an
enabled wb.backup — the backup's master secret is what every device of the account
derives the same delete secrets from, and the enclave keeps one list per account.
await wb.erasure.file() // file now, e.g. right after a sign-in or a restore (it also runs by itself)
await wb.erasure.status() // → { filed, rooms, updated }
wb.erasure.available // true when this page can file: a tenant, a sign-in and a backupfiled: false, rooms: 0fromerasemeans no device of that person ever filed: nothing in the page met the three conditions above.- A room created before October 2026, or while no backup was on, has no commitment.
erasecannot delete it;admin.rooms.delete(room)can, if you know the room is theirs alone. - To clear out rooms nobody opens, rather than one person's, see the idle rule.
Machine-readable source: sdk-builder.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)