Webhooks
WordPlus Cloud tells your server what happens in your app: the events pages send that your rules ask to hear, rooms filling and emptying, people joining presence rooms, users coming online and going offline, and live streams going live and ending.
Where they go
Set the address on your app page in your WordPlus Cloud account, such as https://shop.example.com/api/realtime/webhook. It must be a public HTTPS address: WordPlus Cloud never calls into a private network. On your computer, a tunnel gives your server one (while you develop).
The request
POST /api/realtime/webhook HTTP/1.1
Content-Type: application/json
X-WordPlus-Site: s1500000123
X-WordPlus-Timestamp: 1791638400
X-WordPlus-Signature: 7471cbdc121b23bfe8bb75b94442c65413c1f338089ae53baf3e6e5e9eee27d5
{"events":[{"id":"0b6f1c9e-8f5d-4c38-9d2b-6a1e2f3c4d5e","at":1791638400000,"type":"room.occupied","room":"order:42"}]}
- Batched: the events of about a second come together, up to 100 in a request.
- At least once: each event has its
id. Keep the ids you took for a day, and drop a repeat. - Answer 2xx once you took it, within 10 seconds. Any other answer, or none, and the batch is sent again 30 seconds later, then after 2, 10 and 30 minutes, and 1, 2, 4 and 8 hours, for 24 hours at most.
- In order within a batch, not across retries: a late batch may come after a newer one.
Checking it
A webhook is signed as a request to the API is, with the webhook key, HMAC-SHA256( secret, "wordplus-realtime webhook v1" ):
X-WordPlus-Siteis your app key.X-WordPlus-Timestampis within 5 minutes of your clock.X-WordPlus-Signatureis the hex HMAC-SHA256, under the webhook key, of"{timestamp}\nPOST\n{path with query}\n{sha256 hex of the body}": the path your address has, and the body exactly as it came, before any parsing.
Then take the events. The helpers on signing requests do all three:
Node
import { createServer } from 'node:http';
import { webhookEvents } from './wordplus.mjs';
createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => (raw += chunk));
req.on('end', async () => {
try {
for (const event of webhookEvents(req.headers, req.url, raw)) await handle(event);
res.writeHead(204).end();
} catch (error) {
res.writeHead(401).end();
}
});
}).listen(8080);
Python (Flask, or the raw body and headers of any framework)
from wordplus import webhook_events
@app.post("/api/realtime/webhook")
def realtime_webhook():
headers = {name.lower(): value for name, value in request.headers.items()}
try:
events = webhook_events(headers, request.full_path.rstrip("?"), request.get_data())
except ValueError:
return "", 401
for event in events:
handle(event)
return "", 204
PHP
require 'wordplus.php';
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$events = wordplus_webhook_events($headers, $_SERVER['REQUEST_URI'], file_get_contents('php://input'));
} catch (RuntimeException $e) {
http_response_code(401);
exit;
}
foreach ($events as $event) {
handle($event);
}
http_response_code(204);
Go
http.HandleFunc("/api/realtime/webhook", func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
events, err := app.Webhook(r.Header, r.URL.RequestURI(), raw)
if err != nil {
w.WriteHeader(401)
return
}
for _, event := range events {
handle(event)
}
w.WriteHeader(204)
})
The events
type | Its fields | When |
|---|---|---|
emit | room, event, data, from, event_id | a page sent an event that the room token names in w (the room rule webhook). from is the user who sent it, event_id the id the pages saw. data is sealed in a sealed room: open it with the key of the room (sealing) |
room.occupied, room.vacated | room | the first one in, the last one out |
member.joined, member.left | room, user | in a presence room |
user.online, user.offline | user | a user connected anywhere in your app, and gone 10 seconds after their last connection |
stream.live, stream.ended | stream, started_at, ended_at | a live stream went live and ended, the times in milliseconds |
call.ended | call, call_id, from, to, media, started_at, answered_at, ended_at, seconds, reason | a call between two people ended. seconds is the time they talked, from the answer to the end, 0 for a call never answered. Its id is the same however often it is sent |
group.joined, group.left | call, user, media, joined_at, and on leaving left_at and seconds | someone joined or left a group call, by the name your rules gave it |
stream.joined, stream.left | stream, user, role, watch, joined_at, and on leaving left_at and seconds | a signed-in person started or stopped watching or presenting while the stream was live: stream.joined as they open it live or it goes live with them on it, stream.left as they leave or it ends, seconds the live time alone. watch and joined_at pair the two, and going live again starts another pair. Visitors are not told |
webhook.test | your server asked for one (POST /wordplus/realtime/webhooks/test), to see its webhook take it |
Every event also has id and at (milliseconds).
- Check what matters: an
emitcomes from a page, so it says what the page sent.fromis who sent it, as your server vouched for them. - Keep the conversation from
emit: save each message by itsevent_id, and a page reconnecting later reads it from your own route.
Check yours
The test values have a signed webhook: with their secret, its signature over their path and body at their timestamp must be theirs, 7471cbdc121b23bfe8bb75b94442c65413c1f338089ae53baf3e6e5e9eee27d5.
Then on your server, once it is set up:
wordplus POST /wordplus/realtime/webhooks/test # {"ok":true,"delivered":true,"status":204}
wordplus GET /wordplus/realtime/webhooks # the last 100 tries, the newest first
Each delivery has the id of its batch, the HTTP status your server answered (0 with error when nothing answered: timeout, unreachable, not_public), when it is tried again (next_at), and whether it may be sent again now (resendable). POST /wordplus/realtime/webhooks/{id}/resend sends a batch your server did not take again, the same events with the same ids, while it is tried again and for three days after (reference).