Gareth Wilson Gareth Wilson

Guide to Liveblocks Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationProject dashboard, Webhooks tab
FilteringAll events by default, narrowed with Message Filtering
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
SecretPer endpoint, whsec_...
BodyJSON
Event routingBody field type
RetriesEight attempts over about 28 hours, then replay or recover from the dashboard
ThrottlingstorageUpdated and ydocUpdated at most once every 60 seconds
Source IPsNone. The old published list is no longer valid

Events and payloads

Bodies are small, with two fields:

FieldMeaning
typeThe camelCase event type, for example storageUpdated. This is what handlers switch on
dataEvent-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:

EventTriggered Whendata fields
userEnteredA user connects to a roomconnectionId, userId (or null), userInfo (or null), enteredAt, numActiveUsers
userLeftA user disconnects from a roomconnectionId, userId, userInfo, leftAt, numActiveUsers
storageUpdatedA user writes to Storage. Throttled at 60 supdatedAt
roomCreatedA room is created by the API or by a user connecting to a missing roomcreatedAt
roomDeletedA room is deleteddeletedAt
ydocUpdatedA Yjs document in a room changes. Throttled at 60 supdatedAt
commentCreatedA comment is createdthreadId, commentId, createdAt, createdBy
commentEditedA comment is editedthreadId, commentId, editedAt
commentDeletedA comment is deletedthreadId, commentId, deletedAt
commentReactionAddedA reaction is added to a commentthreadId, commentId, emoji, addedAt, addedBy
commentReactionRemovedA reaction is removed from a commentthreadId, commentId, emoji, removedAt, removedBy
commentMetadataUpdatedA comment's metadata is updatedthreadId, commentId, updatedAt, updatedBy
threadCreatedA thread is createdthreadId, createdAt, createdBy
threadDeletedAll comments in a thread are deleted, or the thread is deletedthreadId, deletedAt
threadMetadataUpdatedA thread's metadata is updatedthreadId, updatedAt, updatedBy
threadMarkedAsResolvedA thread is marked resolvedthreadId, updatedAt, updatedBy
threadMarkedAsUnresolvedA thread is marked unresolvedthreadId, updatedAt, updatedBy
notificationA user has an unseen notification; used to build notification emailschannel ("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.kindMeaningExtra fields
threadThe user was mentioned or replied to in a thread, or subscribed to a thread, and hasn't seen itthreadId
textMentionThe user was mentioned in a text editor document and hasn't seen itmentionId
$yourKindA 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

  1. In the Liveblocks dashboard, open the project and click the Webhooks tab.
  2. 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>.
  3. Select the events to subscribe to, then click Add endpoint.
  4. Copy the endpoint's signing secret into LIVEBLOCKS_WEBHOOK_SECRET, keeping the 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 numActiveUsers from 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.