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:
| Scope | Calls |
|---|---|
rooms:read | ListWorlds, ListRooms, ListPlayers |
members:read | ListMembers |
members:write | SetMember, RemoveMember |
variables:read | GetRoomVariables |
variables:write | SetRoomVariable |
events:write | SendEvent |
usage:read | GetUsage |
maps:write | the 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:read | ListRecordings, GetRecordingDownload |
recordings:write | DeleteRecording |
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,ListRoomslist the tenant's worlds, and the rooms of a world, in the order of their slugs. A room'spolicyispublic,members,inviteorsso.ListPlayerslists 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.ListMemberslists 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.SetMembermakes an account of the tenant a member of a world with a role,ROLE_GUESTtoROLE_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.RemoveMemberends 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.GetRoomVariablesanswers 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.SetRoomVariablesets 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.valueis any JSON value;nullclears the variable.SendEventhands a script event to the scripts of a room's players, or of the players of the accounts inuserIds, which hear it throughworld.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.GetUsageanswers the tenant's monthly counters from the month insince, written2026-10, or from the current month; it reaches back 24 months at most. The metrics are those of the plans (Central admin):people_online,roomsandstorage_bytescount the month's peak, andrecording_minutes,api_callsandworld_invitesits 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:
| Code | HTTP | Meaning |
|---|---|---|
invalid_argument | 400 | a malformed id, cursor, role, tag, name, value or month |
unauthenticated | 401 | no key, or a key that is unknown, revoked, expired or another tenant's |
permission_denied | 403 | the key lacks the call's scope |
not_found | 404 | no tenant at this host, or no such world, room, account or member |
resource_exhausted | 429 | the key made too many calls, or the tenant made as many this month as its plan allows (Tenancy); retry after Retry-After |
unimplemented | 501 | no such call |
unavailable | 503 | the server is shutting down or cannot reach its database; retry |
internal | 500 | a 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.
| Topic | When | data |
|---|---|---|
player.joined | an account enters a room with its first player there | worldId, roomId, userId, name |
player.left | an account's last player leaves a room | worldId, roomId, userId |
moderation.kicked | a moderator kicks an account out of a room | worldId, roomId, userId, moderatorId, reason when given |
moderation.banned | a moderator bans an account from the room's world | the same, and expiresAt for a ban that ends |
moderation.removed | a moderator removes an account from a space | the same, and space |
recording.completed | a recording's file is ready to download | recordingId, 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:
| Header | Value |
|---|---|
VWS-Event | the topic |
VWS-Delivery | the delivery's number, the same when it is sent again |
VWS-Signature | t=<Unix time of sending>,v1=<hex HMAC-SHA256 of t, a dot and the body, keyed with the secret> |
Content-Type | application/json |
User-Agent | vws-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
worldprocess 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.