Webhooks
Webhooks push events that happen in your world to a URL on your own server, as they happen: a recording is ready, a visitor signed up, someone withdrew their consent. Each world can have several endpoints, each subscribed to the events it cares about.
| You want to… | Subscribe to |
|---|---|
| Archive meeting recordings or send them to the participants | recording |
| Add new visitors to your CRM or mailing list | visitor and consent |
| Keep your mailing lists in line with what people consented to | consent |
| Update a member's tags from your own system each time they connect | auth |
| Feed your own dashboards with raw activity | analytics (experimental) |
Setting up an endpoint
Go to Developers > Webhook settings in the admin dashboard and click Add webhook:
- Enter the URL of your endpoint. It must use HTTPS and be reachable from the internet (private addresses are refused).
- Tick the subscriptions you want to receive.
- Copy the secret shown after creation. It is displayed once; you can generate a new one later, which invalidates the previous one immediately.
Click an endpoint in the list to open its page: there you can edit the URL and the events, disable or re-enable it, delete it, send a test event and browse its delivery attempts with their request and response.
A minimal receiver
This Node.js receiver checks the signature, deduplicates, answers at once and only then does the work. Click Test on the endpoint page to see it receive a sample.
import express from "express";
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…
const app = express();
// The signature covers the raw bytes: do not let express.json() parse the body first.
app.post("/workadventure", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.sendStatus(400);
}
res.sendStatus(204);
if (alreadyProcessed(event.deliveryId)) return; // retries reuse the same deliveryId
switch (event.type) {
case "recording.completed": /* download event.data.downloadUrl */ break;
case "consent.changed": /* update your mailing lists */ break;
// Ignore types you do not know: new ones can be added.
}
});
app.listen(3000);
Delivery format
Every delivery is a POST with a JSON body carrying one event, whose type is at the top level:
{
"deliveryId": "7f2c9b8e-4d1a-4c2e-9f3b-0a1b2c3d4e5f",
"apiVersion": "2026-09-01",
"type": "recording.completed",
"timestamp": "2026-09-04T10:22:30Z",
"world": {
"slug": "office",
"url": "https://play.workadventu.re/@/acme/office/"
},
"data": { "...": "see the catalogue below" }
}
| Field | Meaning |
|---|---|
deliveryId | Identifies this POST. Deduplicate on it: every retry, and a replay from the dashboard, reuses it with a byte-identical body. |
apiVersion | Frozen for an endpoint when it is created. A payload can change shape in a later version without affecting you. |
type | One of the types below. |
timestamp | When the event happened. A retry hours later keeps it; the time of each attempt is in the signed webhook-timestamp header. |
data | The event's own payload, always an object ({} when the event has none). |
test | Present and true only for deliveries sent from the Test button. |
Every payload is also described as an OpenAPI 3.1 document, whose JSON Schemas you can validate against or generate types from:
Delivery guarantees
- At least once. A delivery whose answer got lost is sent again: deduplicate on
deliveryId. - Not in order. A retried delivery can arrive after a newer one. When order matters (two
consent.changedfor the same person and purpose, for instance), keep the one with the latesttimestamp. - No catch-up. Events that happen while an endpoint is disabled are never delivered.
Headers
Deliveries follow Standard Webhooks, so one of its libraries can verify them for you.
| Header | Value |
|---|---|
Content-Type | application/json |
webhook-id | Same as deliveryId in the body. |
webhook-timestamp | Unix time (seconds) of this attempt. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of your secret after whsec_. Always present, except on a legacy endpoint that never had a secret. |
Verifying the signature
Use a Standard Webhooks library: standardwebhooks on npm, standard-webhooks/standard-webhooks on Packagist, and
others for most languages. Give it your
secret as displayed (whsec_…), the headers and the raw request body, not a re-serialized version of it. It
checks the signature and refuses timestamps more than 5 minutes away, so a captured delivery cannot be replayed later.
Node.js:
import { Webhook } from "standardwebhooks";
// Throws when the delivery is not authentic.
const payload = new Webhook(secret).verify(rawBody, req.headers);
PHP (the library expects lowercase header names):
$webhook = new \StandardWebhooks\Webhook($secret);
// Throws when the delivery is not authentic.
$payload = $webhook->verify(file_get_contents('php://input'), array_change_key_case(getallheaders()));
Answering
Answer with any 2xx status within 15 seconds, ideally at once, then process the events: slow work (downloading a
recording, calling your CRM) belongs after the answer, or in a queue. Anything you return in the body is kept in
the delivery logs (first kilobytes), which helps when debugging.
Retries
| Answer | What WorkAdventure does |
|---|---|
2xx | Done. |
408, 429, 5xx, timeout (15 s), connection error | Retried up to 9 more times, over about three days: after 5 seconds, 5 minutes, 30 minutes, 2, 5, 10, 14, 20 and 24 hours (each delay up to 10% longer, at random). |
410 Gone | Not retried, and the endpoint is disabled at once: answer this when you no longer want deliveries. |
Any other 4xx | Not retried: you told us the request itself was wrong. |
After 50 consecutive failed attempts, or a 410 Gone, the endpoint is disabled and the world's administrators get an email. Events
that happen while an endpoint is disabled are not delivered later. Re-enable it from the dashboard once your receiver
is back.
Subscriptions and event catalogue
An endpoint subscribes to one or more subscriptions, each delivering one or more event types. Route on the
type of each delivery, not on the subscription, and ignore the types you do not know.
| Subscription | Events delivered |
|---|---|
recording | recording.completed |
visitor | visitor.registered |
consent | consent.changed |
analytics | Experimental. Every analytics event of the world, batched as analytics.batch, each under its own name (user.connected, user.disconnected, meeting.ended, chat.message_sent, media.video_quality.sample, …) |
auth | member.connecting, sent synchronously while a member connects, see below |
recording.completed
Sent as its own POST when a recording of a meeting is ready. data:
{
"recordingId": "EG_5b1d2c8e9f3a4b7c",
"room": { "url": "https://play.workadventu.re/@/acme/office/meeting-room" },
"recorder": {
"uuid": "9d4c6e2a-1b3f-4a5e-8c7d-0f1e2d3c4b5a",
"name": "Ana Ferreira",
"email": "[email protected]"
},
"startedAt": "2026-09-04T10:00:00Z",
"endedAt": "2026-09-04T10:42:17Z",
"durationSeconds": 2537,
"sizeBytes": 812345678,
"downloadUrl": "https://recordings.example.com/9d4c6e2a-.../recording-2026-09-04T10:00:00.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...",
"expiresAt": "2026-12-04T10:00:00Z"
}
recorder is the member who started the recording: uuid as returned by the Inbound API, plus
their name and email so you can write to them once the recording is processed (null when the recorder was a
visitor without a member account). downloadUrl is valid for 7 days, retries included, and can be fetched by anyone
who has it: download the file on your side if you need it longer. A Replay from the dashboard re-sends the original
link, which may have expired by then. expiresAt is when WorkAdventure deletes the recording (three months after it was made).
Recordings that fail do not produce an event.
visitor.registered
Sent when a visitor account becomes usable in the world: right away when the visitor signed up through SSO, and once the email is verified when they used the self-register form (a visitor who never verifies their email produces nothing). Members you invite yourself are not visitors and do not produce this event.
{
"uuid": "5f2e8d1c-7a4b-4c3d-9e0f-1a2b3c4d5e6f",
"name": "Ana Ferreira",
"email": "[email protected]",
"via": "email"
}
uuid is the visitor's identifier as returned by the Inbound API. via is email
(self-register form) or sso (OpenID Connect or a social login). name and email are personal
data of your visitor: your privacy policy applies to what your receiver does with them.
consent.changed
Sent as its own POST for every consent decision recorded in the world, one per purpose: a visitor ticking a box when
registering or when asked at login, anyone changing their mind on their privacy settings page, and the decisions your
administrators record on someone's behalf (dashboard, CSV import, Inbound API). Restating a decision that did not
change sends nothing. data:
{
"uuid": "5f2e8d1c-7a4b-4c3d-9e0f-1a2b3c4d5e6f",
"email": "[email protected]",
"type": "visitor",
"purpose": "newsletter",
"granted": true
}
typeismemberorvisitor, as in the Inbound API.purposeis one of the purposes declared on the Privacy / Compliance page that need consent:newsletter,events,feedback_survey,third_party,communityorcustom.grantedistrue(given),false(refused or withdrawn) ornull(an administrator took the decision off the record; the person will be asked again). Stop using the data for that purpose as soon as you receivefalseornull.
Analytics events
analytics.batch and the analytics events it carries are experimental: event names and their properties may still
change, without a new apiVersion. Do not build anything you cannot adapt quickly on them yet.
The analytics stream is a firehose (a world in meetings produces one media.video_quality.sample every 5
seconds per user), so its events travel batched, at most 100 per POST. A batch is just another event, of type
analytics.batch, whose data.events[] keeps each analytics event under its own name. Its timestamp is when the
batch was assembled; each inner event carries its own:
{
"deliveryId": "7f2c9b8e-4d1a-4c2e-9f3b-0a1b2c3d4e5f",
"apiVersion": "2026-09-01",
"type": "analytics.batch",
"timestamp": "2026-09-04T12:31:41Z",
"world": { "slug": "office", "url": "https://play.workadventu.re/@/acme/office/" },
"data": {
"events": [
{
"id": "d3b1f0e2-6c7a-4b8d-9e0f-1a2b3c4d5e6f",
"type": "user.disconnected",
"timestamp": "2026-09-04T12:31:40Z",
"data": { "...": "see below" }
}
]
}
}
Batches follow what the server sends, so you get at most a few deliveries per minute however busy the world is.
Each inner event looks like this:
{
"id": "…",
"type": "user.disconnected",
"timestamp": "2026-09-04T12:31:40Z",
"data": {
"eventName": "user.disconnected",
"source": "pusher",
"eventTime": "2026-09-04T12:31:40Z",
"user": { "uuid": "9d4c6e2a-1b3f-4a5e-8c7d-0f1e2d3c4b5a" },
"room": { "url": "https://play.workadventu.re/@/acme/office/open-space" },
"properties": {
"connectedAt": "2026-09-04T08:58:03Z",
"disconnectedAt": "2026-09-04T12:31:40Z",
"durationSeconds": 12817,
"disconnectReason": "socket_closed"
}
}
}
sourceisfront(sent by the browser),pusher(measured by the server, authoritative for durations and sessions) ormedia.propertiesis the event's own payload; it differs per event name. The analytics events schema (JSON Schema) describes every event name and its properties, and follows the product as it evolves. Any property can be missing, since your world's consent settings remove some, and new ones can appear.- Event names are grouped in families, each governed by one of the analytics categories of your world settings:
| Category | Families |
|---|---|
| Presence and sessions | user.connected, user.disconnected, session.*, status.* |
| Collaboration | bubble.*, chat.*, conversation.*, meeting.*, megaphone.*, global_message.*, global_audio.* |
| Workspace actions | area.*, cowebsite.*, map_editor.*, menu.*, room.*, invite.*, file.*, emote.*, popup.*, … |
| Quality diagnostics | media.* (including media.video_quality.sample), websocket.*, asset.*, map_loading.* |
New event names can appear as the product evolves: match on the families you need and ignore the rest.
A world that disabled analytics sends nothing, and a
world that opted out of user-level activity receives pseudonymous user.uuid values (the same pseudonyms as in its
dashboards), not member identifiers. Recording events do not depend on analytics settings.
member.connecting
Unlike every other event, this one is sent synchronously while a member is being authenticated, and the access check waits for your answer (up to 5 seconds). That is the point: your server can change the member's tags through the Inbound API before access is granted.
{
"deliveryId": "7f2c9b8e-4d1a-4c2e-9f3b-0a1b2c3d4e5f",
"apiVersion": "2026-09-01",
"type": "member.connecting",
"timestamp": "2026-09-04T10:22:31Z",
"world": { "slug": "office", "url": "https://play.workadventu.re/@/acme/office/" },
"data": {
"roomUrl": "https://play.workadventu.re/@/acme/office/meeting-room",
"uuid": "39afd085-c606-4a3e-b68e-d1d7ebeff82b",
"accessTokens": [
{ "provider": "a1b2c3d4", "token": "your_access_token" }
]
}
}
accessTokens holds the OAuth2 access tokens of the member, one per provider you configured in
OpenID Connect authentication that they signed in with, labelled with the provider's id
(the {id} of its /oauth/{id}/callback redirect URI). Only providers set to expose the access token are included.
Tokens of the generic Google, Microsoft, Discord, GitHub or LinkedIn logins are never sent. It is empty for an
anonymous member. Nothing you return in the body is read: answer 2xx and
apply your changes through the Inbound API before you do. Every endpoint subscribed to auth receives the hook, one
after the other, so keep your receiver fast.
The hook never blocks access: when your endpoint times out, fails or answers an error, the member connects with the tags they already had. It is not retried, and its failures do not count toward disabling the endpoint.
The auth event fires for visitors too. The payload only carries the uuid, so fetch the
rest with the Inbound API: GET /api/v1/worlds/{worldSlug}/members/{uuid} returns the name,
email and consent answers. The event fires on every connection, not once per registration, so upsert on the
uuid in your CRM rather than creating a contact each time.
The legacy body, for endpoints carried over from the single-URL setting
Endpoints inherited from the previous single-URL setting (Developers > Webhook settings before endpoints existed) keep
the historic flat body, so nothing had to change on your side. It carries a single accessToken: the first of
accessTokens, or null when there is none.
{
"roomUrl": "https://play.workadventu.re/@/acme/office/meeting-room",
"uuid": "39afd085-c606-4a3e-b68e-d1d7ebeff82b",
"accessToken": "your_access_token",
"action": "auth"
}
Those deliveries have no envelope (so no deliveryId in the body, no world, no test marker; the webhook-id
header is still there) and are unsigned until you generate a secret: webhook-signature is added as soon as you do,
over the same body.
The endpoint page shows which of the two formats an endpoint is on and switches it, both ways: Use the current format once your receiver reads the envelope, Back to the legacy body if it turns out it does not. Nothing else about the endpoint changes, secret and history included. Endpoints you create yourself are on the current format from the start.
Testing and debugging
- Test sends a realistic sample for one of your subscriptions (or a
pingevent, whosedatais empty) (with"test": truein the envelope) through the same signing and checks as a real delivery, and shows the status, latency and response. It works on a disabled endpoint. Amember.connectingtest to an endpoint on the legacy body has nowhere to carry thetestmarker: it looks exactly like a real pre-authentication hook, with a sample member. - The Deliveries table on the endpoint page lists its attempts for the last 90 days with the request headers and body and the response. Replay queues a past delivery again with the same delivery id and body.