Skip to content

Administration

Central admin

A multi-tenant platform (VWS_TENANCY=multi) has a central admin above its tenants, where its operators create, suspend and delete tenants, define plans and give them to tenants, invite their first admins, manage the identity providers every tenant may offer, and read the platform's health and log. Operators are not accounts of any tenant. A single-tenant platform has no central admin: every operator endpoint answers 404 there, and the tenant's own administration (Tenancy) covers everything.

Host

The central admin answers on VWS_OPERATOR_HOST, such as console.example.com, which production requires with multi (Configuration). The host serves no tenant: its pages lead to /operator, and its API is /api/v1/operator/*. On any other host the operator endpoints answer 404. The host needs a DNS record and a certificate like a tenant's, at the edge.

Development defaults the host to console.localhost, which browsers send to the machine, and also answers at /operator on every host.

Operators

The first operator comes from the command line:

vws operator add --email ops@example.com
vws operator list

Adding an address twice keeps the first. Operators then add and remove each other on the Operators page; an operator never removes itself, so someone always remains. A removed operator's sessions end at its next request.

Signing in

Operators sign in through the platform identity provider of VWS_OIDC_*, which production requires with multi:

  1. GET /api/v1/operator/sign-in sends the browser to the provider with PKCE, a state and a nonce, kept for 10 minutes.
  2. The provider comes back to https://<VWS_OPERATOR_HOST>/api/v1/operator/callback, the redirect URI to register at the provider. The server exchanges the code, verifies the ID token and its nonce, and takes its email claim, which must not be marked unverified, as the operator's address.
  3. The callback sends the browser to /operator#code=<one-time code>, or to /operator#error=not_operator for an address no operator has and /operator#error=sign_in_failed otherwise. The code never reaches a server log as part of a URL path or query.
  4. The console trades the code at POST /api/v1/operator/session with {"code": "..."} for a session token, once and within a minute.

In development, the same endpoint takes {"email": "..."} of an operator instead, and the sign-in page offers a field for it; outside development it refuses that body.

The session token, vws_op_ and 43 characters, travels as Authorization: Bearer <token> and lasts 12 hours. Redis keeps its SHA-256 under vws:operator-session:*. DELETE /api/v1/operator/session signs out, and GET /api/v1/operator/me names the operator. The client keeps the token apart from a tenant's access token, in localStorage under vws.operatorToken, and never refreshes it: a 401 returns the console to its sign-in page.

Tenants

GET /api/v1/operator/tenants lists every tenant, those whose deletion waits included, the oldest first, each with its domains and their verification states, its accounts and worlds, its month under its plan (Plans) and its waiting admin invites; the answer also names VWS_TENANT_DOMAIN. GET /api/v1/operator/tenants/{tenant} answers one.

  • Creating. POST /api/v1/operator/tenants takes a slug, a name, an active plan (standard without one), hosts and the first admin's address. The tenant gets <slug>.<VWS_TENANT_DOMAIN> as its primary domain; every host given is a host the platform controls, verified at once, and without VWS_TENANT_DOMAIN the first one is the primary domain. A taken slug or host answers 409.
  • First admins. With an address, and later through POST /api/v1/operator/tenants/{tenant}/admin-invites, the platform mails the address an invite in the tenant's language, linking to /admin on the tenant's primary domain with the scheme and port of the console's request. The invite lasts 7 days; inviting the address again renews it. The account that first signs in to the tenant with that address, through any of its providers, becomes the tenant's admin, and the invite is used up (Identity). Without an email backend the invite is still stored, and the operator tells the admin to sign in. A new tenant whose invite mail fails is kept without the invite, the server logs cannot invite the first admin of a new tenant, and the tenant's page says so, for the operator to invite again.
  • Renaming and plans. PATCH /api/v1/operator/tenants/{tenant} changes the name or moves the tenant to another active plan; an unknown or retired plan answers 422 and changes nothing. A plan in another currency blocks the tenant's extra charges again and sets its cap to 0, as the cap was an amount in the old currency.
  • Suspending. POST .../suspend stops the tenant's hosts from serving it: its pages show the suspended page, every endpoint of its hosts answers 403 with the problem code suspended, the partner API answers permission_denied, and the realtime gateway refuses joins and ends every live session of the tenant with ERROR_CODE_SUSPENDED. POST .../resume serves it again. Nothing of the tenant is lost meanwhile, and its sweeps go on.
  • Deleting. DELETE /api/v1/operator/tenants/{tenant} marks the tenant deleted; its hosts behave as suspended for 30 days, and purgeAt says until when. POST .../restore cancels the deletion. Once the 30 days have passed, the sweep deleted-tenants deletes the tenant's objects under tenants/<id>/ in object storage and then the tenant, whose rows the database removes with it.

Plans

A plan says what a tenant may use and what usage past a limit costs. Operators manage the plans under /api/v1/operator/plans, and every tenant is on one. The platform starts with standard, 5 world invites a month and nothing else limited, and with development and self-hosted, which limit nothing; all three are in euros, and operators change or add plans freely.

  • Terms. A plan has a slug and a currency, an ISO 4217 code, which never change after POST, a name, a status, active or retired, and limits. A limit names a metric, its quota, and optionally a price per unit past the quota in minor units of the plan's currency; a metric without a limit is unlimited, and a limit without a price can never be exceeded.

    MetricCountsQuota inA price is per
    people_onlinethe accounts online at once across the tenant, by the month's peakpeopleperson
    roomsthe tenant's rooms, by the month's peakroomsroom
    storage_bytesthe tenant's stored files, by the month's peakbytesgigabyte, 10^9 bytes
    recording_minutesminutes recorded in the monthminutesminute
    api_callspartner API calls in the monthcalls1,000 calls
    world_invitesworld invites sent in the monthinvitesinvite

    Months are calendar months in UTC. The excess is rounded up to whole units of the price: a byte past a 10 GB quota is charged as a gigabyte.

  • Changes. PUT /api/v1/operator/plans/{plan} sets a plan's name, status and limits, which its tenants follow at once. A retired plan keeps its tenants and takes no new ones. DELETE removes a plan no tenant is on and answers 409 for one with tenants. Bad terms answer 422 and a taken slug 409.

  • Tenants decide. A tenant's admins choose on the Usage page of their administration (Tenancy) whether usage past a limit is refused, the default, or charged up to a cap a month.

  • Enforcement. Each limit applies where its usage grows: a join past the people online, a new room, an upload, a recording, a partner API call or a world invite past its limit is refused, unless the tenant pays for it within its cap (Tenancy). The tenant's admins receive an email when a metric reaches 80 % and 100 % of its limit, and when the extra charges reach 80 % and 100 % of the cap, once a month each.

Billing webhook

One webhook of the platform tells an invoicing system each tenant's usage under its plan, the limits it reaches and its changes of plan. Operators set it on the Billing page of the central admin, or with the API:

  • Settings. PUT /api/v1/operator/billing-webhook with {"url": "https://billing.example.com/vws"} creates the webhook and answers 201 with the secret that signs its deliveries, vws_whsec_ and 43 characters, which is never shown again. The URL is https, or also http on a development server, with no credentials in it and at most 2000 characters. The same call later changes the URL and the status, active or disabled, answers 200 and keeps the secret; the deliveries waiting go to the new URL. A disabled webhook receives nothing new, and its deliveries wait until it is active again. DELETE removes the webhook with the deliveries waiting for it; the next webhook gets a new secret. GET answers the webhook, or 404 while there is none. The platform's log keeps billing_webhook.created, billing_webhook.updated and billing_webhook.deleted.
  • Tests and deliveries. POST /api/v1/operator/billing-webhook/test sends an active webhook an event of the topic webhook.test, with empty data, which travels, is signed and is retried like any other, and goes to the log as billing_webhook.tested; a disabled webhook refuses it with 422. GET /api/v1/operator/billing-webhook/deliveries lists the latest 20 deliveries, the newest first: each with its number, the tenant it is about, its topic, its status (pending, delivered or failed), its attempts, when it was queued and delivered, when a pending one is sent next, and why its latest attempt failed.
TopicWhendata
usage.dailyonce a day for each tenant, shortly after midnight UTCthe tenant's month so far, as GET /api/v1/admin/usage answers it (Tenancy): tenantId, tenantSlug, plan with its slug, name and currency, month as 2026-10, lines, charges and extraCharges
usage.month_closedonce for each month and each tenant that existed in it, right after the month endsthe same of the month that ended, from its counters alone, under the plan the tenant is on when the month closes
limit.reacheda tenant's usage reaches a limit of its plan, or its extra charges reach their cap; once a month eachtenantId, tenantSlug, month, limit, a metric or extra_charges for the cap, used and quota in the metric's units, or for the cap in minor units of currency, and overage: charged where usage past the limit is charged as extra, refused where it is refused
tenant.plan_changedan operator moves a tenant to another plantenantId, tenantSlug, plan and previousPlan, each with its slug, name and currency, and the tenant's extraCharges after the move
webhook.testan operator sends a test{}
{
  "topic": "usage.month_closed",
  "occurredAt": "2026-11-01T00:05:12Z",
  "data": {
    "tenantId": "0b6c6f7e-3d1f-4bd5-9a0a-2f8f6f3b1c42",
    "tenantSlug": "acme",
    "plan": { "slug": "team", "name": "Team", "currency": "EUR" },
    "month": "2026-10",
    "lines": [
      { "metric": "people_online", "used": 14, "quota": 10, "unitPrice": 500, "extraUnits": 4, "charge": 2000 },
      { "metric": "rooms", "used": 6, "extraUnits": 0, "charge": 0 }
    ],
    "charges": 2000,
    "extraCharges": { "status": "charged", "cap": 5000 }
  }
}

A line names a metric, what was used, and where the plan limits the metric its quota and the unitPrice past it, with the units past the quota and their charge; amounts are minor units of the plan's currency. Every metric has a line (Plans).

  • Delivery. Deliveries are signed, sent and retried like the webhooks of tenants (Partner API): a POST with a JSON body of topic, occurredAt and data, signed in VWS-Signature with the secret, and the delivery's number in VWS-Delivery, which stays the same when it is sent again and is the event's identity. An answer with a 2xx status within 10 seconds delivers it; otherwise it is sent again 30 seconds later, then after twice as long each time, up to 6 hours, 12 attempts in all, and then given up. Deliveries arrive at least once and in no guaranteed order: a receiver ignores a VWS-Delivery it has handled, and orders the reports of a tenant by month and occurredAt.
  • The outbox. An event is queued in the transaction of what it tells: a change of plan with the change, a limit reached with the alert that records it, and a report with the record that it went out, so that each goes out once. Usage is reviewed every 15 minutes (Operations). Nothing is queued while the platform has no active webhook: once it is active, the next review reports the day, and the month before when its close has not gone out yet, while the days, limits and changes of plan in between are not reported again.
  • Addresses. Outside development, deliveries never connect to private, loopback, link-local or shared address space, whatever the URL's host name resolves to, and go through no proxy.
  • Retention. Deliveries that were delivered or given up are deleted a week after they were queued.

Identity providers

The platform's OpenID Connect providers, managed under /api/v1/operator/identity-providers with the same body and rules as a tenant's own (Identity), are offered to every tenant, which chooses in its administration which it offers; a tenant's own provider shadows a platform provider of the same slug. The provider must accept the callback of every tenant host, https://<host>/api/v1/oauth/callback.

Log

Every change an operator makes goes to operator_audit_event, which the platform's database role can only insert into and read: operator.added, operator.removed, operator.signed_in, tenant.created, tenant.updated, tenant.admin_invited, tenant.suspended, tenant.resumed, tenant.deleted, tenant.restored, plan.created, plan.updated, plan.deleted, provider.created, provider.updated and provider.deleted, with the operator and its IP address, and tenant.purged from the sweep, without one. Additions on the command line have no operator either. GET /api/v1/operator/audit-events pages it, the newest first. A rename of a tenant also goes to the tenant's own audit log as tenant.updated by the platform.

Health

GET /api/v1/operator/health runs the readiness checks of the process that answers, the same as /readyz (Operations), and names its version. The console's overview shows them beside the tenants by state and what they hold together.

License

On a multi-tenant platform, operators keep the platform's license under License in the console, at /api/v1/operator/license: an extended license, which the platform needs to run, activated with its key, checked every day, and released to move it to another install (Licensing).

Console

The console at /operator is part of the web client, loaded apart from the rooms, in the shadcn components of the administration: an overview with the health and the tenants by state, the tenants with a page each, the plans, the billing webhook, every tenant's domains, the platform's identity providers, the operators and the log.

Found a mistake? Tell us.