Skip to main content

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 participantsrecording
Add new visitors to your CRM or mailing listvisitor and consent
Keep your mailing lists in line with what people consented toconsent
Update a member's tags from your own system each time they connectauth
Feed your own dashboards with raw activityanalytics (experimental)

Setting up an endpoint​

Go to Developers > Webhook settings in the admin dashboard and click Add webhook:

  1. Enter the URL of your endpoint. It must use HTTPS and be reachable from the internet (private addresses are refused).
  2. Tick the subscriptions you want to receive.
  3. 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" }
}
FieldMeaning
deliveryIdIdentifies this POST. Deduplicate on it: every retry, and a replay from the dashboard, reuses it with a byte-identical body.
apiVersionFrozen for an endpoint when it is created. A payload can change shape in a later version without affecting you.
typeOne of the types below.
timestampWhen the event happened. A retry hours later keeps it; the time of each attempt is in the signed webhook-timestamp header.
dataThe event's own payload, always an object ({} when the event has none).
testPresent 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.changed for the same person and purpose, for instance), keep the one with the latest timestamp.
  • 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.

HeaderValue
Content-Typeapplication/json
webhook-idSame as deliveryId in the body.
webhook-timestampUnix time (seconds) of this attempt.
webhook-signaturev1, 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​

AnswerWhat WorkAdventure does
2xxDone.
408, 429, 5xx, timeout (15 s), connection errorRetried 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 GoneNot retried, and the endpoint is disabled at once: answer this when you no longer want deliveries.
Any other 4xxNot 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.

SubscriptionEvents delivered
recordingrecording.completed
visitorvisitor.registered
consentconsent.changed
analyticsExperimental. 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, …)
authmember.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
}
  • type is member or visitor, as in the Inbound API.
  • purpose is one of the purposes declared on the Privacy / Compliance page that need consent: newsletter, events, feedback_survey, third_party, community or custom.
  • granted is true (given), false (refused or withdrawn) or null (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 receive false or null.

Analytics events​

Experimental

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"
}
}
}
  • source is front (sent by the browser), pusher (measured by the server, authoritative for durations and sessions) or media.
  • properties is 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:
CategoryFamilies
Presence and sessionsuser.connected, user.disconnected, session.*, status.*
Collaborationbubble.*, chat.*, conversation.*, meeting.*, megaphone.*, global_message.*, global_audio.*
Workspace actionsarea.*, cowebsite.*, map_editor.*, menu.*, room.*, invite.*, file.*, emote.*, popup.*, …
Quality diagnosticsmedia.* (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.

Analytics events follow your world's analytics settings

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 ping event, whose data is empty) (with "test": true in 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. A member.connecting test to an endpoint on the legacy body has nowhere to carry the test marker: 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.
info

Need help 🆘

Contact us or write us to [email protected]