Skip to content

Developers

Partner API and webhooks

A tenant's own systems, such as its intranet, booking tool or CRM, work with its worlds through the partner API, and hear what happens in them through webhooks. Both belong to one tenant: an API key works on that tenant's hosts only, and a webhook receives that tenant's events only. The tenant's admins manage both through /api/v1/admin, as HTTP API describes.

API keys

POST /api/v1/admin/api-keys with {"name": "CRM", "scopes": ["rooms:read", "events:write"]} creates a key, and expiresAt makes it stop working at that time. The answer carries the key itself in secret, vws_ and 43 characters, which is never shown again: the server keeps only its SHA-256. prefix, the first 12 characters, tells keys apart in GET /api/v1/admin/api-keys, which lists the keys that are not revoked, with lastUsedAt to the minute. DELETE /api/v1/admin/api-keys/{key} revokes a key at once. Creating and revoking keys goes to the audit log.

A call sends the key as Authorization: Bearer <key> to one of the tenant's hosts. Each call needs one scope:

ScopeCalls
rooms:readListWorlds, ListRooms, ListPlayers
members:readListMembers
members:writeSetMember, RemoveMember
variables:readGetRoomVariables
variables:writeSetRoomVariable
events:writeSendEvent
usage:readGetUsage
maps:writethe map package upload of the HTTP API, POST /api/v1/maps and PUT /api/v1/maps/{map}/package, for a build pipeline (Map format)
recordings:readListRecordings, GetRecordingDownload
recordings:writeDeleteRecording

A key makes 20 calls a second, in bursts of up to 40; beyond that, calls fail with resource_exhausted and a Retry-After header in seconds. Changes made through a key are audited with the key's id as the actor.

Calls

proto/vws/partner/v1/partner.proto defines the service vws.partner.v1.PartnerService, served with Connect at /vws.partner.v1.PartnerService/<call> on the tenant's hosts, outside /api/v1. One definition speaks three protocols: the Connect protocol over HTTP/1.1 or HTTP/2, with JSON or binary protobuf bodies; gRPC; and gRPC-Web. Partners generate a client from the .proto file with their language's Connect or gRPC tooling, or post JSON:

curl -s https://acme.example.com/vws.partner.v1.PartnerService/ListRooms \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"worldId": "0b6c6f7e-3d1f-4bd5-9a0a-2f8f6f3b1c42"}'

JSON bodies use the field names of the .proto file in lower camel case, enums by name, such as "role": "ROLE_EDITOR", and fields left out take their zero value. gRPC needs HTTP/2: over TLS, or without it, as behind a load balancer that ends TLS, with prior knowledge. The server has no reflection; grpcurl reads the .proto file:

grpcurl -plaintext -import-path proto -proto vws/partner/v1/partner.proto -H "authorization: Bearer $KEY" \
  -d '{}' localhost:8080 vws.partner.v1.PartnerService/ListWorlds

Listings answer a page of up to limit items, 50 when it is 0 and 100 at most, and nextCursor, which the next call passes as cursor; the last page has none.

  • ListWorlds, ListRooms list the tenant's worlds, and the rooms of a world, in the order of their slugs. A room's policy is public, members, invite or sso.
  • ListPlayers lists the players in a room now, in the order they joined; a room nobody is in has none. The players are those of the rooms the answering process runs, which are all of them while one process runs the rooms.
  • ListMembers lists the active members of a world, in the order of their account ids, with each one's display name, role, tags and the time it joined.
  • SetMember makes an account of the tenant a member of a world with a role, ROLE_GUEST to ROLE_OWNER, and up to 20 tags of 1 to 32 characters, or replaces the role and tags of a membership; a suspended membership becomes active, and a meeting's guest a member of the whole world. Tags decide the variables an account reads and writes and the areas it enters (Map format). Players already in a room keep the rights they entered with until they enter again.
  • RemoveMember ends a membership. Players already in a room stay until they leave; kicking and banning are moderation, which the partner API leaves to the room's moderators.
  • GetRoomVariables answers every variable that the room's map declares with its value, whatever tags its readers need. A room nobody is in starts for the read and stops again, so the answer holds the kept values and the defaults of the others.
  • SetRoomVariable sets a room variable that the room's map declares, whatever tags its writers need, and the room's players whose tags read it hear the change. A room nobody is in starts for the write and stops again, so a variable the map persists reaches the database, and one it does not persist is gone at once. value is any JSON value; null clears the variable.
  • SendEvent hands a script event to the scripts of a room's players, or of the players of the accounts in userIds, which hear it through world.events.on(name) without a sender (Scripting API). The name has 1 to 100 bytes and the data at most 64 KiB; a room nobody is in drops the event.
  • GetUsage answers the tenant's monthly counters from the month in since, written 2026-10, or from the current month; it reaches back 24 months at most. The metrics are those of the plans (Central admin): people_online, rooms and storage_bytes count the month's peak, and recording_minutes, api_calls and world_invites its total; a metric appears once something counted it that month.

Errors carry a Connect code, which gRPC clients see as the matching status and JSON clients as {"code": "...", "message": "..."} with an HTTP status:

CodeHTTPMeaning
invalid_argument400a malformed id, cursor, role, tag, name, value or month
unauthenticated401no key, or a key that is unknown, revoked, expired or another tenant's
permission_denied403the key lacks the call's scope
not_found404no tenant at this host, or no such world, room, account or member
resource_exhausted429the key made too many calls, or the tenant made as many this month as its plan allows (Tenancy); retry after Retry-After
unimplemented501no such call
unavailable503the server is shutting down or cannot reach its database; retry
internal500a failure the server logged

Webhooks

POST /api/v1/admin/webhooks with {"url": "https://hooks.example.com/vws", "topics": ["player.joined", "player.left"]} creates a webhook, active unless status is disabled. The answer carries 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. PUT /api/v1/admin/webhooks/{webhook} replaces its URL, topics and status; disabling a webhook drops the deliveries waiting for it, and so does DELETE. Changes go to the audit log. The administration manages them, and API keys, on its own pages.

POST /api/v1/admin/webhooks/{webhook}/test sends an active webhook an event of the topic webhook.test, whose data names the webhook as webhookId; it travels, is signed and is retried as any other event, and goes to the audit log as webhook.tested. No webhook subscribes to the topic, so a receiver sees it only when an admin sends it, and should answer it with a 2xx status like any other. A disabled webhook refuses the test with 422. GET /api/v1/admin/webhooks/{webhook}/deliveries lists the webhook's latest 20 deliveries, newest first: each with its number, the VWS-Delivery header of its requests, 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
player.joinedan account enters a room with its first player thereworldId, roomId, userId, name
player.leftan account's last player leaves a roomworldId, roomId, userId
moderation.kickeda moderator kicks an account out of a roomworldId, roomId, userId, moderatorId, reason when given
moderation.banneda moderator bans an account from the room's worldthe same, and expiresAt for a ban that ends
moderation.removeda moderator removes an account from a spacethe same, and space
recording.completeda recording's file is ready to downloadrecordingId, worldId, roomId and recordedBy while they exist, durationSeconds, sizeBytes

A delivery is a POST to the webhook's URL with a JSON body:

{ "topic": "player.joined", "occurredAt": "2026-10-14T09:30:00Z", "data": { "worldId": "…", "roomId": "…", "userId": "…", "name": "Ada" } }

and these headers:

HeaderValue
VWS-Eventthe topic
VWS-Deliverythe delivery's number, the same when it is sent again
VWS-Signaturet=<Unix time of sending>,v1=<hex HMAC-SHA256 of t, a dot and the body, keyed with the secret>
Content-Typeapplication/json
User-Agentvws-webhooks/1

A receiver computes the HMAC over the body exactly as received, compares it with v1 in constant time, refuses a t more than five minutes old so that a recorded delivery cannot be sent again later, and ignores a VWS-Delivery it has already handled. examples/partner shows it in Go.

  • Success. An answer with a 2xx status within 10 seconds delivers the event. Redirects are not followed, and count as failures like any other status, a timeout or a refused connection.
  • Retries. A failed delivery is sent again 30 seconds later, then after twice as long each time, up to 6 hours, 12 attempts in all over about 14 and a half hours; then it is given up. Deliveries arrive at least once and in no guaranteed order, so receivers order events by occurredAt.
  • The outbox. Moderation events are written to the outbox in the transaction that records the moderation, so an event exists exactly when its change does. Joins and leaves change nothing in the database: a queue in each world process writes them within moments, and a process that crashes loses the ones it had not written yet. A full queue, of 4096 events, drops events with a warning in the log.
  • Addresses. Outside development, deliveries never connect to private, loopback, link-local or shared address space, whatever the URL's host name resolves to at the time, and they go through no proxy.
  • Retention. Deliveries that were delivered or given up are deleted a week after they were written.

Processes

Processes in modes all and world serve the partner API, because its room calls reach the rooms of the answering process; until rooms are placed across processes, one world process runs them all. Processes in modes all and control deliver webhooks, polling every 2 seconds; several share the work by claiming deliveries for a minute each, and the deliveries of a process that dies are claimed again when that minute ends. Operations covers running them.

Sample client

examples/partner is a small Go program that calls the partner API with the generated Connect client and receives webhooks. With a key that holds rooms:read and events:write, against a development server:

go run ./examples/partner -url http://localhost:8080 -key "$KEY" rooms
go run ./examples/partner -url http://localhost:8080 -key "$KEY" event -room <room id> -name news -data '{"headline":"Lunch is served"}'
go run ./examples/partner -listen :9000 -secret "$SECRET" receive

rooms lists every world's rooms with the players in each, event sends a script event to a room, and receive answers webhooks at http://localhost:9000/, printing each verified event and refusing the others with 401.

Found a mistake? Tell us.