# Guide to Liveblocks Webhooks: Features and Best Practices

[Liveblocks](https://liveblocks.io) 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/liveblocks/latest/commentCreated.json). Note that it carries IDs only, not the comment text:

```json
{
  "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](https://liveblocks.io/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](/docs/cli) forwards it to your local app:

```bash
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:

```javascript
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:

```javascript
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](/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](/docs/destinations) 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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) filters Liveblocks events by type and keeps them through outages. [Sign up and try it](https://dashboard.hookdeck.com/signup).