Guide to Liveblocks Webhooks: Features and Best Practices
Liveblocks provides the realtime layer for collaborative apps: rooms with presence, Storage for shared state, Yjs documents, Comments and notifications. Most of that runs between browsers and Liveblocks' servers, out of your backend's sight. Webhooks bring it back: they tell your server when people join and leave rooms, when a document changes, when someone comments or resolves a thread, and when a user has a notification they haven't seen.
What are Liveblocks webhooks?
Each Liveblocks project has a Webhooks tab where you add endpoints. An endpoint receives every event unless you narrow it with Message Filtering, and the tab's Event Catalog shows each event's schema.
Liveblocks signs deliveries to the Standard Webhooks specification with a whsec_ secret per endpoint, and retries failures on the same schedule Svix uses. Its @liveblocks/node package includes a WebhookHandler that verifies requests for you.
Liveblocks webhook features
| Feature | Details |
|---|---|
| Configuration | Project dashboard, Webhooks tab |
| Filtering | All events by default, narrowed with Message Filtering |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Secret | Per endpoint, whsec_... |
| Body | JSON |
| Event routing | Body field type |
| Retries | Eight attempts over about 28 hours, then replay or recover from the dashboard |
| Throttling | storageUpdated and ydocUpdated at most once every 60 seconds |
| Source IPs | None. The old published list is no longer valid |
Events and payloads
Bodies are small, with two fields:
| Field | Meaning |
|---|---|
type | The camelCase event type, for example storageUpdated. This is what handlers switch on |
data | Event-specific fields. projectId and roomId are on every event except a custom notification, whose roomId can be null |
There's no event ID in the body. The webhook-id header is unique per message and stays the same across retries, so use it as the idempotency key.
Liveblocks documents 18 events:
| Event | Triggered When | data fields |
|---|---|---|
userEntered | A user connects to a room | connectionId, userId (or null), userInfo (or null), enteredAt, numActiveUsers |
userLeft | A user disconnects from a room | connectionId, userId, userInfo, leftAt, numActiveUsers |
storageUpdated | A user writes to Storage. Throttled at 60 s | updatedAt |
roomCreated | A room is created by the API or by a user connecting to a missing room | createdAt |
roomDeleted | A room is deleted | deletedAt |
ydocUpdated | A Yjs document in a room changes. Throttled at 60 s | updatedAt |
commentCreated | A comment is created | threadId, commentId, createdAt, createdBy |
commentEdited | A comment is edited | threadId, commentId, editedAt |
commentDeleted | A comment is deleted | threadId, commentId, deletedAt |
commentReactionAdded | A reaction is added to a comment | threadId, commentId, emoji, addedAt, addedBy |
commentReactionRemoved | A reaction is removed from a comment | threadId, commentId, emoji, removedAt, removedBy |
commentMetadataUpdated | A comment's metadata is updated | threadId, commentId, updatedAt, updatedBy |
threadCreated | A thread is created | threadId, createdAt, createdBy |
threadDeleted | All comments in a thread are deleted, or the thread is deleted | threadId, deletedAt |
threadMetadataUpdated | A thread's metadata is updated | threadId, updatedAt, updatedBy |
threadMarkedAsResolved | A thread is marked resolved | threadId, updatedAt, updatedBy |
threadMarkedAsUnresolved | A thread is marked unresolved | threadId, updatedAt, updatedBy |
notification | A user has an unseen notification; used to build notification emails | channel ("email"), kind, userId, inboxNotificationId, createdAt, triggeredAt, plus kind-specific fields |
Two of those are throttled to one event every 60 seconds, so storageUpdated and ydocUpdated tell you a room changed recently, not what changed. Fetch the current Storage or Yjs document through the REST API when you need the content.
notification events exist to drive notification emails, and their data.kind says what kind:
data.kind | Meaning | Extra fields |
|---|---|---|
thread | The user was mentioned or replied to in a thread, or subscribed to a thread, and hasn't seen it | threadId |
textMention | The user was mentioned in a text editor document and hasn't seen it | mentionId |
$yourKind | A custom notification you triggered from your server; any string starting with $ | roomId can be null |
In a notification, createdAt is when the webhook event was created and triggeredAt is when the notification itself was. By default the event is sent 30 minutes after the activity. You can change the delay in the project settings.
This is the commentCreated sample from the webhook samples library. Note that it carries IDs only, not the comment text:
{
"type": "commentCreated",
"data": {
"projectId": "my-project-id",
"roomId": "my-room-id",
"threadId": "my-thread-id",
"commentId": "my-comment-id",
"createdAt": "2021-10-06T01:45:56.558Z",
"createdBy": "my-user-id"
}
}
Setting up Liveblocks webhooks
- In the Liveblocks dashboard, open the project and click the Webhooks tab.
- Click Add endpoint... and enter your URL, or click use a test endpoint to get a temporary one at
https://webhooks.liveblocks.io/test/<token>. - Select the events to subscribe to, then click Add endpoint.
- Copy the endpoint's signing secret into
LIVEBLOCKS_, keeping theWEBHOOK_ SECRET whsec_prefix.
To change the events later, open the endpoint, choose Edit endpoint... from its menu, and save.
To route Liveblocks through Hookdeck, create a source with the Liveblocks type, paste the secret, and use the source URL as the endpoint. The Hookdeck CLI forwards it to your local app:
npx hookdeck-cli listen 3000 liveblocks --path /webhooks/liveblocks
Open a room in two browsers and post a comment to see userEntered and commentCreated arrive.
Don't firewall the endpoint by source IP. Liveblocks no longer sends from a fixed set of addresses and has withdrawn the list it used to publish, so the signature is the only check on the sender.
Securing Liveblocks webhooks
Liveblocks' docs describe the standard construction: concatenate webhook-id, webhook-timestamp and the raw body with full stops, compute HMAC-SHA256 with the base64-decoded part of the secret after whsec_, and compare with each entry in webhook-signature. Their examples show v2 entries alongside v1; only the v1, entries are HMAC signatures, and the function below checks each of them:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Liveblocks' sample code allows a five-minute tolerance on webhook-timestamp. Hookdeck has none, and its retries and replays reuse the original timestamp, so a receiver behind Hookdeck should check the signature only, as above.
Because comment and storage events carry IDs only, the receiver stores the event and leaves the API calls to a worker:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// roomEvents.add(webhookId, event) records the event and resolves once it is durable.
function createLiveblocksReceiver(roomEvents) {
const app = express();
app.post('/webhooks/liveblocks', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.LIVEBLOCKS_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
try {
await roomEvents.add(req.headers['webhook-id'], event);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
A Hookdeck Liveblocks source returns 200 to Liveblocks even when the signature is wrong. Hookdeck records the request as failed verification and doesn't pass it on.
Make Liveblocks webhooks production-ready. Hookdeck Event Gateway verifies every Liveblocks event and can route presence, comment and notification events to different services.
Liveblocks webhook limitations and pain points
Change events are throttled and content-free
The Problem: You want to index every edit to a document, but you get one ydocUpdated a minute with only updatedAt.
Why It Happens: Liveblocks throttles storageUpdated and ydocUpdated to once every 60 seconds, and neither carries the changed data.
Workarounds:
- Treat the event as a prompt to fetch the room's current Storage or Yjs document through the REST API.
- If you need every change, capture it client-side instead.
How Hookdeck Can Help: Hookdeck can rate-limit delivery to the worker that fetches documents, so many busy rooms don't overload it.
Presence events are high volume
The Problem: userEntered and userLeft dominate your webhook traffic.
Why It Happens: An endpoint listens to everything by default, and every connection and disconnection in every room is an event.
Workarounds:
- Use Message Filtering to drop presence events from endpoints that don't need them.
- Use
numActiveUsersfrom the event rather than counting joins yourself.
How Hookdeck Can Help: Hookdeck filters on type let one Liveblocks endpoint send presence to analytics and comments to your app.
Endpoints are disabled after five days
The Problem: An endpoint stops getting events after a long outage, even once it's fixed.
Why It Happens: Liveblocks disables an endpoint when every delivery has failed for five consecutive days.
Workarounds:
- Re-enable the endpoint, then replay or bulk-recover failed messages from the dashboard.
How Hookdeck Can Help: Hookdeck accepts Liveblocks' deliveries while your service is down, so the endpoint never fails, and retries your service when it's back.
Delivery details are undocumented
The Problem: You can't size timeouts or rely on event order.
Why It Happens: Liveblocks documents the retry schedule but not a response timeout or any ordering guarantee, and names no headers beyond the three webhook-* ones.
Workarounds:
- Respond as soon as the event is stored, and order by the timestamps inside
data.
How Hookdeck Can Help: Hookdeck answers Liveblocks immediately and gives you your own timeout and retry settings for your service.
Best practices
Use WebhookHandler for direct receivers
If Liveblocks posts straight to your server, WebhookHandler from @liveblocks/node verifies and parses in one call. Behind Hookdeck, keep the version-agnostic function above, which applies no age check.
Use notifications for email, not in-app badges
The notification event is sent after a delay and only for notifications the user hasn't seen, which is what an email needs. Build in-app indicators from the Liveblocks client instead.
Handle custom notification kinds
Any kind starting with $ is one you triggered. Its roomId can be null, so don't assume a room.
Make handlers idempotent
Store webhook-id with the change it causes so retries, replays and recoveries don't repeat it. The idempotency guide explains the approach.
Conclusion
Liveblocks sends 18 compact events about rooms, documents, comments and notifications. Most carry IDs rather than content, so pair the receiver with fetch workers, and keep it healthy because five days of failures disables the endpoint.
Hookdeck Event Gateway filters Liveblocks events by type and keeps them through outages. Sign up and try it.