Webhooks
Signed calls to a backend when a phone adds an activity, or the user turns it off or deletes it.
A webhook endpoint is a URL the service sends a POST to. Add endpoints in the dashboard. Each one gets its own signing secret (whs_ and 48 hex characters) and can have an identifier. Two endpoints in one team cannot have the same identifier. The URL must be http or https.
Routing by identifier
An activity started with a webhookIdentifier sends every event to the one endpoint with that identifier. An activity started without one sends events to every endpoint with no identifier. An event with no matching endpoint is dropped.
This is also how to test: there is no separate test environment. Keep the live endpoint without an identifier, and start test activities with the test endpoint's identifier:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
curl -X POST https://api.webliveactivity.com/api/start \ -H 'Content-Type: application/json' \ -H 'x-api-key: wla_your_key' \ -d '{ "widgetContainerId": "acme-orders", "webhookIdentifier": "staging", "brand": { "title": "Order #1234" }, "state": { "layout": "progress", "primaryText": "Order received", "progress": 0.1 } }'
The events
| Event | Sent when |
|---|---|
active | A phone opened the activity and it is on screen (once per phone), or the user turned it on again in the app. |
removed | The user switched the activity off in the app, deleted it, or cleared the app's data. |
POST /api/end and restarts send no webhook.
The delivery
Each event is one POST with a JSON body:
1 2 3 4 5 6 7 8
{ "activityId": "act-8f2cqa4mx7rd", "group": "order-tracker", "event": "active", "active": true, "webhookIdentifier": "staging", "at": "2026-07-30T14:25:19.991Z" }
| Field | Meaning |
|---|---|
activityId | The activity. With reuse: true, this is the phone's own copy, not the id start returned. |
group | The activity's group. "" when none was set. |
event | active or removed. |
active | The activity's on-screen flag at that moment. Act on event, not on this flag. |
webhookIdentifier | The identifier from start. null when none was set. |
at | When the event happened, as an ISO time. |
And three headers:
| Header | Meaning |
|---|---|
X-WebActivities-Event | The event name: active or removed. |
X-WebActivities-Timestamp | Unix epoch seconds when this attempt was sent. Each retry gets a new one. Reject old timestamps. |
X-WebActivities-Signature | The HMAC signature described below. |
Verifying the signature
The signature is an HMAC-SHA256 of the timestamp and the raw request body, keyed with the endpoint's secret. The timestamp is inside the signed payload, so a captured delivery cannot be replayed later with a new timestamp.
1 2 3 4 5 6 7 8 9 10 11 12
const { createHmac, timingSafeEqual } = require('node:crypto'); // rawBody is the request body EXACTLY as received, before any JSON parsing. const verify = (secret, headers, rawBody) => { const timestamp = headers['x-webactivities-timestamp']; const expected = 'sha256=' + createHmac('sha256', secret) .update(timestamp + '.' + rawBody) .digest('hex'); const presented = Buffer.from(headers['x-webactivities-signature']); return expected.length === presented.length && timingSafeEqual(Buffer.from(expected), presented); };
Compare in constant time (timingSafeEqual) so a wrong signature leaks nothing about the right one, and check the timestamp against a local clock to block replays.
Retries
- The endpoint has 8 seconds to answer with a 2xx. A redirect counts as a failure.
- A failed delivery is tried 5 times in total, waiting 1, 2, 4, and 8 minutes between tries.
- URLs that point to private or internal addresses are refused.
- The dashboard's delivery log shows every delivery for 90 days and can send one again.
Answering fast
Save the event and answer 200 first, then act on it. Slow work inside the request risks the 8 second timeout and a retry that arrives as a duplicate. Retries can arrive out of order, so use the at field as the event's real time.









