Platform - Webhooks

Webhooks enable developers to extend the Liveblocks platform. From your system, you can listen to events that get automatically triggered as users interact with collaborative rooms.

Configuring webhooks

To set up webhooks for your project, you’ll need to create an endpoint, subscribe to events, and secure your endpoint.

Creating an endpoint

If you would like to create an endpoint to receive webhook events, you will do so from within the webhooks dashboard for your project.

  1. From the dashboard overview, navigate to the project you’d like to add webhooks to.

  2. Click on the webhooks tab from the left-hand menu.

  3. Click the “Create endpoint…” button.

  4. Enter the URL of the endpoint you would like to use. Configure with your own endpoint or generate a Svix playground link by clicking on "use Svix play".

  5. Select the events you would like to subscribe to.

  6. Click “Create endpoint”.

Your endpoint must return a 2xx (status code 200-299) to indicate that the event was successfully received. If your endpoint returns anything else, the event will be retried, see replaying events for more details.

If all events fail to be delivered to your endpoint for 5 consecutive days, your endpoint will automatically be disabled. You can always re-enable it from the dashboard.

Edit endpoint events

You can easily edit the events you want to subscribe to after creating an endpoint.

  1. Select the endpoint you would like to edit from the list of webhooks in the dashboard.

  2. Select “Edit endpoint…” from the top right dropdown.

  3. Update event selections and click “Save changes”.

Replaying events

If your service is unreachable, message retries are automatically re-attempted. If your service incurs considerable downtime (over 8 hours), you can replay individual messages from the Endpoints portion of the dashboard by clicking the kebab menu on an individual message, or you can opt to bulk replay events by clicking the top right dropdown and selecting “Recover failed messages…”.

Each message is attempted based on a schedule that follows the failure of the preceding attempt. If an endpoint is removed or disabled, delivery attempts will also be disabled. The schedule for retries is as follows:

  • Immediately
  • 5 seconds
  • 5 minutes
  • 30 minutes
  • 2 hours
  • 5 hours
  • 10 hours
  • 10 hours (in addition to the previous)

For example, an attempt that fails three times before eventually succeeding will be delivered roughly 35 minutes and 5 seconds following the first attempt.

Security verification

Verifying webhooks prevents security vulnerabilities by safeguarding against man-in-the-middle, CSRF, and replay attacks. Because of this, it is essential to prioritize verification in your integration. We recommend using the @liveblocks/node package to verify and return fully typed events.

  1. Install the package

    $npm install @liveblocks/node
  2. Set up the webhook handler

    Set up your webhook handler, inserting your secret key from the webhooks dashboard you set up earlier into WebhookHandler.

    import { WebhookHandler } from "@liveblocks/node";
    // Insert your webhook secret keyconst webhookHandler = new WebhookHandler("whsec_...");
  3. Verify an event request

    We can verify a genuine webhook request with WebhookHandler.verifyRequest

    const event = webhookHandler.verifyRequest({  headers: req.headers,  rawBody: req.body,});

    The method will return a WebhookEvent object that is fully typed. You can then use the event to perform actions based on the event type. If the request is not valid, an error will be thrown.

  4. Full example

    Here’s an example from start to finish.

    import { WebhookHandler } from "@liveblocks/node";
    // Will fail if not properly initialized with a secretconst webhookHandler = new WebhookHandler("whsec_...");
    export default function webhookRequestHandler(req, res) { try { const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: req.body, });
    // Use the event, for example... if (event.type === "storageUpdated") { // { roomId: "my-room-name", projectId: "8sfhs5s...", ... } console.log(event.data); } } catch (error) { console.error(error); return res.status(400).end(); }
    res.status(200).end();}

Manually verify

It’s also possible to manually verify your webhooks, though it’s unlikely this’ll be necessary.

How to manually verify webhook events
  1. Construct the signed content

    The content to sign is composed by concatenating the request’s id, timestamp, and payload, separated by the full-stop character (.). In code, it will look something like:

    const crypto = require("crypto");
    // webhookId comes from the `webhook-id` header// webhookTimestamp comes from the `webhook-timestamp` header// body is the request bodysignedContent = `${webhookId}.${webhookTimestamp}.${body}`;
  2. Generate the signature

    Liveblocks uses an HMAC with SHA-256 to sign its webhooks.

    So to calculate the expected signature, you should HMAC the signedContent from above using the base64 portion of your webhook secret key (this is the part after the whsec_ prefix) as the key. For example, given the secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw you will want to use MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.

    For example, this is how you can calculate the signature in Node.js:

    // Your endpoint’s secret keyconst secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
    // Need to base64 decode the secretconst secretBytes = new Buffer(secret.split("_")[1], "base64");// This is the signature you will compare against the signature headerconst signature = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
  3. Validate the signature

    The generated signature should match one of the signatures sent in the webhook-signature header.

    The webhook-signature header comprises a list of space-delimited signatures and their corresponding version identifiers. The signature list is most commonly of length one. Though there could be any number of signatures. For example:

    v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=

    Make sure to remove the version prefix and delimiter (e.g., v1) before verifying the signature.

  4. Verify the timestamp

    As mentioned above, Liveblocks also sends the timestamp of the attempt in the webhook-timestamp header. You should compare this timestamp against your system timestamp and make sure it’s within your tolerance to prevent timestamp attacks.

Testing locally

Running webhooks locally can be difficult, but one way to do this is to use a tool such as localtunnel or ngrok which allow you to temporarily host your localhost server online.

If your project is running on localhost:3000, you can run the following command to generate a temporary URL that’s available while your localhost server is running:

$npx localtunnel --port 3000

If you visit the page localtunnel links you to, and correctly input your IP address, the URL it generates can be placed into the Liveblocks webhooks dashboard for quick testing.

For a full step-by-step guide on testing with localtunnel and ngrok, read the guide on how to test webhooks on localhost.

Liveblocks events

An event occurs when a change is made to Liveblocks data. Each endpoint you provide in the webhooks dashboard listens to all events by default but can be easily configured to only listen to a subset by updating the Message Filtering section.

The Event Catalog in the webhooks dashboard provides a list of events available for subscription, along with their schema.

Events available for use include:

  • StorageUpdated
  • UserEntered/UserLeft
  • RoomCreated/RoomDeleted
  • YDocUpdated
  • CommentCreated/CommentEdited/CommentDeleted
  • CommentReactionAdded/CommentReactionRemoved
  • ThreadCreated/ThreadMetadataUpdated
  • Notification

More events will come later, such as:

  • MaxConnectionsReached

UserEnteredEvent

When a user connects to a room, an event is triggered, indicating that the user has entered. The numActiveUsers field shows the number of users in the room after the user has joined. This event is not throttled.

// Schematype UserEnteredEvent = {  type: "userEntered";  data: {    projectId: string;    roomId: string;    connectionId: number;    userId: string | null;    userInfo: Record<string, any> | null;    enteredAt: string;    numActiveUsers: number;  };};
// Exampleconst userEnteredEvent = { type: "userEntered", data: { projectId: "my-project-id", roomId: "my-room-id", connectionId: 4, userId: "a-user-id", userInfo: null, enteredAt: "2021-10-06T01:45:56.558Z", numActiveUsers: 8, },};

UserLeftEvent

A user leaves a room when they disconnect from a room, which is when this event is triggered. The numActiveUsers field represents the number of users in the room after the user has left. This event, like UserEntered, is not throttled.

// Schematype UserLeftEvent = {  type: "userLeft";  data: {    projectId: string;    roomId: string;    connectionId: number;    userId: string | null;    userInfo: Record<string, any> | null;    leftAt: string;    numActiveUsers: number;  };};
// Exampleconst userLeftEvent = { type: "userLeft", data: { projectId: "my-project-id", roomId: "my-room-id", connectionId: 4, userId: "a-user-id", userInfo: { name: "John Doe", }, leftAt: "2021-10-06T01:45:56.558Z", numActiveUsers: 7, },};

StorageUpdatedEvent

Storage is updated when a user writes to storage. This event is throttled at five seconds and, as such, may not be triggered for every write.

For example, if a user writes to storage at 1:00 pm sharp, the StorageUpdatedEvent event will be triggered shortly after. If the user writes to storage again at 1:00 pm and 2 seconds, the StorageUpdatedEvent event will be triggered five seconds after the first event was sent, around 1:00 pm and 5 seconds.

// Schematype StorageUpdatedEvent = {  type: "storageUpdated";  data: {    roomId: string;    projectId: string;    updatedAt: string;  };};
// Exampleconst storageUpdatedEvent = { type: "storageUpdated", data: { projectId: "my-project-id", roomId: "my-room-id", updatedAt: "2021-10-06T01:45:56.558Z", // 👈 time of the last write },};

RoomCreatedEvent

An event is triggered when a room is created. This event is not throttled. There are two ways for rooms to be created:

  • By calling the create room API
  • When a user connects to a room that does not exist
// Schematype RoomCreatedEvent = {  type: "roomCreated";  data: {    projectId: string;    roomId: string;    createdAt: string;  };};
// Exampleconst roomCreatedEvent = { type: "roomCreated", data: { projectId: "my-project-id", roomId: "my-room-id", createdAt: "2021-10-06T01:45:56.558Z", },};

RoomDeletedEvent

An event is triggered when a room is deleted. This event is not throttled.

// Schematype RoomDeletedEvent = {  type: "roomDeleted";  data: {    projectId: string;    roomId: string;    deletedAt: string;  };};
// Exampleconst roomDeletedEvent = { type: "roomDeleted", data: { projectId: "my-project-id", roomId: "my-room-id", deletedAt: "2021-10-06T01:45:56.558Z", },};

YDocUpdatedEvent

Yjs document is updated when a user makes a change to a Yjs doc connected to a room. This event is throttled at five seconds and, as such, may not be triggered for every write.

For example, if a user updates a Yjs document at 1:00 pm sharp, the YDocUpdatedEvent event will be triggered shortly after. If the user writes to the Yjs document again at 1:00 pm and 2 seconds, the YDocUpdatedEvent event will be triggered 5 seconds after the first event was sent, around 1:00 pm and 5 seconds.

// Schematype YDocUpdatedEvent = {  type: "ydocUpdated";  data: {    projectId: string;    roomId: string;    updatedAt: string;  };};
// Exampleconst ydocUpdatedEvent = { type: "ydocUpdated", data: { projectId: "my-project-id", roomId: "my-room-id", updatedAt: "2013-06-26T19:10:19Z", },};

CommentCreatedEvent

An event is triggered when a comment is created. This event is not throttled.

// Schematype CommentCreatedEvent = {  type: "commentCreated";  data: {    projectId: string;    roomId: string;    threadId: string;    commentId: string;    createdAt: string;    createdBy: string;  };};
// Exampleconst commentCreatedEvent = { 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", },};

CommentEditedEvent

An event is triggered when a comment is edited. This event is not throttled.

// Schematype CommentEditedEvent = {  type: "commentEdited";  data: {    projectId: string;    roomId: string;    threadId: string;    commentId: string;    editedAt: string;  };};
// Exampleconst commentEditedEvent = { type: "commentEdited", data: { projectId: "my-project-id", roomId: "my-room-id", threadId: "my-thread-id", commentId: "my-comment-id", editedAt: "2021-10-06T01:45:56.558Z", },};

CommentDeletedEvent

An event is triggered when a comment is deleted. This event is not throttled.

// Schematype CommentDeletedEvent = {  type: "commentDeleted";  data: {    projectId: string;    roomId: string;    threadId: string;    commentId: string;    deletedAt: string;  };};
// Exampleconst commentDeletedEvent = { type: "commentDeleted", data: { projectId: "my-project-id", roomId: "my-room-id", threadId: "my-thread-id", commentId: "my-comment-id", deletedAt: "2021-10-06T01:45:56.558Z", },};

CommentReactionAddedEvent

An event is triggered when a reaction is added to a comment. This event is not throttled.

// Schematype CommentReactionAddedEvent = {  type: "commentReactionAdded";  data: {    projectId: string;    roomId: string;    threadId: string;    commentId: string;    emoji: string;    addedAt: string;    addedBy: string;  };};
// Exampleconst commentReactionAddedEvent = { type: "commentReactionAdded", data: { projectId: "my-project-id", roomId: "my-room-id", threadId: "my-thread-id", commentId: "my-comment-id", emoji: "👍", addedAt: "2021-10-06T01:45:56.558Z", addedBy: "my-user-id", },};

CommentReactionRemovedEvent

An event is triggered when a reaction is removed from a comment. This event is not throttled.

// Schematype CommentReactionRemovedEvent = {  type: "commentReactionRemoved";  data: {    projectId: string;    roomId: string;    threadId: string;    commentId: string;    emoji: string;    removedAt: string;    removedBy: string;  };};
// Exampleconst commentReactionRemovedEvent = { type: "commentReactionRemoved", data: { projectId: "my-project-id", roomId: "my-room-id", threadId: "my-thread-id", commentId: "my-comment-id", emoji: "👍", removedAt: "2021-10-06T01:45:56.558Z", removedBy: "my-user-id", },};

ThreadCreatedEvent

An event is triggered when a thread is created. This event is not throttled.

// Schematype ThreadCreatedEvent = {  type: "threadCreated";  data: {    projectId: string;    roomId: string;    threadId: string;    createdAt: string;    createdBy: string;  };};
// Exampleconst threadCreatedEvent = { type: "threadCreated", data: { projectId: "my-project-id", roomId: "my-room-id", threadId: "my-thread-id", createdAt: "2021-10-06T01:45:56.558Z", createdBy: "my-user-id", },};

ThreadMetadataUpdatedEvent

An event is triggered when a thread metadata is updated. This event is not throttled.

// Schematype ThreadMetadataUpdatedEvent = {  type: "threadMetadataUpdated";  data: {    projectId: string;    roomId: string;    threadId: string;    updatedAt: string;    updatedBy: string;  };};
// Exampleconst threadMetadataUpdatedEvent = { type: "threadMetadataUpdated", data: { projectId: "my-project-id", roomId: "my-room-id", threadId: "my-thread-id", updatedAt: "2021-10-06T01:45:56.558Z", updatedBy: "my-user-id", },};

NotificationEvent

An event is triggered 30 minutes after a user has been mentioned or replied to in a thread, and has not seen the thread. It will also be triggered if the user has subscribed to the thread and has not seen the thread. The event won’t be triggered if the user has seen the thread or unsubscribed from the room’s thread notifications.

// Schematype NotificationEvent = {  type: "notification";  data: {    channel: "email";    kind: "thread";    projectId: string;    roomId: string;    userId: string;    threadId: string;    inboxNotificationId: string;    createdAt: string;  };};
// Exampleconst notificationEvent = { type: "notification", data: { channel: "email", kind: "thread", projectId: "my-project-id", roomId: "my-room-id", userId: "my-user-id threadId: "my-thread-id", inboxNotificationId: "my-inbox-notification-id", createdAt: "2021-10-06T01:45:56.558Z", },};

Use Cases

With webhooks, you can subscribe to the events you are interested in, and be alerted of the change when it happens. Powerful ways to leverage webhooks with Liveblocks include:

  • Storage synchronization between room(s) and an internal database
  • Monitoring user activity in a room
  • Notifying the client if maximum concurrency has been reached

Webhooks are an excellent way to reduce development time and the need for polling. By following the steps outlined in this guide, you’ll be able to configure, subscribe to, secure, and replay webhook events with Liveblocks.

If you have any questions or need help using webhooks, please let us know by email or by joining our Discord community! We’re here to help!