Repository: liveblocks/liveblocks
Pull request #2363: Subscribe/unsubscribe: `@liveblocks/node` methods
Author: login: marcbouchenoire
Created: 2025-04-14T13:56:30Z | Merged: 2025-04-22T15:12:16Z

This PR adds `@liveblocks/node` methods for the new APIs added with subscribe/unsubscribe:

- [x] `client.subscribeToThread`
- [x] `client.unsubscribeFromThread`
- [x] `client.getThreadSubscriptions`

I'll do the docs afterwards in https://github.com/liveblocks/liveblocks/pull/2323, and I haven't tackled LB-1857, we can do it in a dedicated PR.


Diff:
diff --git a/packages/liveblocks-core/src/convert-plain-data.ts b/packages/liveblocks-core/src/convert-plain-data.ts
index 39c9102c43..cc0e7398ed 100644
--- a/packages/liveblocks-core/src/convert-plain-data.ts
+++ b/packages/liveblocks-core/src/convert-plain-data.ts
@@ -20,6 +20,8 @@ import type {
   SubscriptionDataPlain,
   SubscriptionDeleteInfo,
   SubscriptionDeleteInfoPlain,
+  UserSubscriptionData,
+  UserSubscriptionDataPlain,
 } from "./protocol/Subscriptions";
 
 /**
@@ -144,6 +146,23 @@ export function convertToSubscriptionData(
   };
 }
 
+/**
+ * Converts a plain user subscription data object (usually returned by the API) to a user subscription data object that can be used by the client.
+ * This is necessary because the plain data object stores dates as ISO strings, but the client expects them as Date objects.
+ * @param data The plain user subscription data object (usually returned by the API)
+ * @returns The rich user subscription data object that can be used by the client.
+ */
+export function convertToUserSubscriptionData(
+  data: UserSubscriptionDataPlain
+): UserSubscriptionData {
+  const createdAt = new Date(data.createdAt);
+
+  return {
+    ...data,
+    createdAt,
+  };
+}
+
 export function convertToThreadDeleteInfo(
   data: ThreadDeleteInfoPlain
 ): ThreadDeleteInfo {
diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts
index 1a8ec4b31d..14ef4222e9 100644
--- a/packages/liveblocks-core/src/index.ts
+++ b/packages/liveblocks-core/src/index.ts
@@ -54,7 +54,9 @@ export {
   convertToCommentData,
   convertToCommentUserReaction,
   convertToInboxNotificationData,
+  convertToSubscriptionData,
   convertToThreadData,
+  convertToUserSubscriptionData,
 } from "./convert-plain-data";
 export type {
   CreateManagedPoolOptions,
@@ -284,6 +286,8 @@ export type {
   SubscriptionDeleteInfo,
   SubscriptionDeleteInfoPlain,
   SubscriptionKey,
+  UserSubscriptionData,
+  UserSubscriptionDataPlain,
 } from "./protocol/Subscriptions";
 export { getSubscriptionKey } from "./protocol/Subscriptions";
 export type { HistoryVersion } from "./protocol/VersionHistory";
diff --git a/packages/liveblocks-core/src/protocol/Subscriptions.ts b/packages/liveblocks-core/src/protocol/Subscriptions.ts
index 2e6c4839fe..968885dd08 100644
--- a/packages/liveblocks-core/src/protocol/Subscriptions.ts
+++ b/packages/liveblocks-core/src/protocol/Subscriptions.ts
@@ -10,6 +10,13 @@ export type SubscriptionData<K extends keyof DAD = keyof DAD> = {
 
 export type SubscriptionDataPlain = DateToString<SubscriptionData>;
 
+export type UserSubscriptionData<K extends keyof DAD = keyof DAD> =
+  SubscriptionData<K> & {
+    userId: string;
+  };
+
+export type UserSubscriptionDataPlain = DateToString<UserSubscriptionData>;
+
 export type SubscriptionDeleteInfo = {
   type: "deletedSubscription";
   kind: NotificationKind;
diff --git a/packages/liveblocks-node/src/__tests__/client.test.ts b/packages/liveblocks-node/src/__tests__/client.test.ts
index b054937d8e..1a44193400 100644
--- a/packages/liveblocks-node/src/__tests__/client.test.ts
+++ b/packages/liveblocks-node/src/__tests__/client.test.ts
@@ -939,6 +939,246 @@ describe("client", () => {
     });
   });
 
+  describe("subscribe to thread", () => {
+    test("should return the created subscription when subscribeToThread receives a successful response", async () => {
+      const now = new Date();
+
+      server.use(
+        http.post(
+          `${DEFAULT_BASE_URL}/v2/rooms/:roomId/threads/:threadId/subscribe`,
+          () => {
+            return HttpResponse.json(
+              {
+                kind: "thread",
+                subjectId: "thread1",
+                createdAt: now.toISOString(),
+                userId: "user-1",
+              },
+              { status: 200 }
+            );
+          }
+        )
+      );
+
+      const client = new Liveblocks({ secret: "sk_xxx" });
+
+      await expect(
+        client.subscribeToThread({
+          roomId: "room1",
+          threadId: "thread1",
+          data: { userId: "user-1" },
+        })
+      ).resolves.toEqual({
+        kind: "thread",
+        subjectId: "thread1",
+        createdAt: now,
+        userId: "user-1",
+      });
+    });
+
+    test("should throw a LiveblocksError when subscribeToThread receives an error response", async () => {
+      const error = {
+        error: "THREAD_NOT_FOUND",
+        message: "Thread not found",
+      };
+
+      server.use(
+        http.post(
+          `${DEFAULT_BASE_URL}/v2/rooms/:roomId/threads/:threadId/subscribe`,
+          () => {
+            return HttpResponse.json(error, { status: 404 });
+          }
+        )
+      );
+
+      const client = new Liveblocks({ secret: "sk_xxx" });
+
+      // This should throw a LiveblocksError
+      try {
+        // Attempt to get, which should fail and throw an error.
+        await client.subscribeToThread({
+          roomId: "room1",
+          threadId: "thread1",
+          data: { userId: "user-1" },
+        });
+        // If it doesn't throw, fail the test.
+        expect(true).toBe(false);
+      } catch (err) {
+        expect(err instanceof LiveblocksError).toBe(true);
+        if (err instanceof LiveblocksError) {
+          expect(err.status).toBe(404);
+          expect(err.message).toBe("Thread not found");
+          expect(err.name).toBe("LiveblocksError");
+        }
+      }
+    });
+  });
+
+  describe("unsubscribe from thread", () => {
+    test("should not return anything when unsubscribeFromThread receives a successful response", async () => {
+      server.use(
+        http.post(
+          `${DEFAULT_BASE_URL}/v2/rooms/:roomId/threads/:threadId/unsubscribe`,
+          () => {
+            return HttpResponse.json(undefined, { status: 204 });
+          }
+        )
+      );
+
+      const client = new Liveblocks({ secret: "sk_xxx" });
+
+      await expect(
+        client.unsubscribeFromThread({
+          roomId: "room1",
+          threadId: "thread1",
+          data: { userId: "user-1" },
+        })
+      ).resolves.not.toThrow();
+    });
+
+    test("should throw a LiveblocksError when unsubscribeFromThread receives an error response", async () => {
+      const error = {
+        error: "THREAD_NOT_FOUND",
+        message: "Thread not found",
+      };
+
+      server.use(
+        http.post(
+          `${DEFAULT_BASE_URL}/v2/rooms/:roomId/threads/:threadId/unsubscribe`,
+          () => {
+            return HttpResponse.json(error, { status: 404 });
+          }
+        )
+      );
+
+      const client = new Liveblocks({ secret: "sk_xxx" });
+
+      // This should throw a LiveblocksError
+      try {
+        // Attempt to get, which should fail and throw an error.
+        await client.unsubscribeFromThread({
+          roomId: "room1",
+          threadId: "thread1",
+          data: { userId: "user-1" },
+        });
+        // If it doesn't throw, fail the test.
+        expect(true).toBe(false);
+      } catch (err) {
+        expect(err instanceof LiveblocksError).toBe(true);
+        if (err instanceof LiveblocksError) {
+          expect(err.status).toBe(404);
+          expect(err.message).toBe("Thread not found");
+          expect(err.name).toBe("LiveblocksError");
+        }
+      }
+    });
+  });
+
+  describe("get thread subscriptions", () => {
+    test("should return the thread subscription when subgetThreadSubscriptionsscribeToThread receives a successful response", async () => {
+      const now = new Date();
+
+      server.use(
+        http.get(
+          `${DEFAULT_BASE_URL}/v2/rooms/:roomId/threads/:threadId/subscriptions`,
+          () => {
+            return HttpResponse.json(
+              {
+                data: [
+                  {
+                    kind: "thread",
+                    subjectId: "thread1",
+                    createdAt: now.toISOString(),
+                    userId: "user-1",
+                  },
+                  {
+                    kind: "thread",
+                    subjectId: "thread1",
+                    createdAt: now.toISOString(),
+                    userId: "user-2",
+                  },
+                  {
+                    kind: "thread",
+                    subjectId: "thread1",
+                    createdAt: now.toISOString(),
+                    userId: "user-3",
+                  },
+                ],
+              },
+              { status: 200 }
+            );
+          }
+        )
+      );
+
+      const client = new Liveblocks({ secret: "sk_xxx" });
+
+      await expect(
+        client.getThreadSubscriptions({
+          roomId: "room1",
+          threadId: "thread1",
+        })
+      ).resolves.toEqual({
+        data: [
+          {
+            kind: "thread",
+            subjectId: "thread1",
+            createdAt: now,
+            userId: "user-1",
+          },
+          {
+            kind: "thread",
+            subjectId: "thread1",
+            createdAt: now,
+            userId: "user-2",
+          },
+          {
+            kind: "thread",
+            subjectId: "thread1",
+            createdAt: now,
+            userId: "user-3",
+          },
+        ],
+      });
+    });
+
+    test("should throw a LiveblocksError when getThreadSubscriptions receives an error response", async () => {
+      const error = {
+        error: "THREAD_NOT_FOUND",
+        message: "Thread not found",
+      };
+
+      server.use(
+        http.get(
+          `${DEFAULT_BASE_URL}/v2/rooms/:roomId/threads/:threadId/subscriptions`,
+          () => {
+            return HttpResponse.json(error, { status: 404 });
+          }
+        )
+      );
+
+      const client = new Liveblocks({ secret: "sk_xxx" });
+
+      // This should throw a LiveblocksError
+      try {
+        // Attempt to get, which should fail and throw an error.
+        await client.getThreadSubscriptions({
+          roomId: "room1",
+          threadId: "thread1",
+        });
+        // If it doesn't throw, fail the test.
+        expect(true).toBe(false);
+      } catch (err) {
+        expect(err instanceof LiveblocksError).toBe(true);
+        if (err instanceof LiveblocksError) {
+          expect(err.status).toBe(404);
+          expect(err.message).toBe("Thread not found");
+          expect(err.name).toBe("LiveblocksError");
+        }
+      }
+    });
+  });
+
   describe("get comment", () => {
     test("should return the specified comment when getComment receives a successful response", async () => {
       server.use(
diff --git a/packages/liveblocks-node/src/client.ts b/packages/liveblocks-node/src/client.ts
index 2a85d35400..c59272528c 100644
--- a/packages/liveblocks-node/src/client.ts
+++ b/packages/liveblocks-node/src/client.ts
@@ -38,10 +38,14 @@ import type {
   RoomSubscriptionSettings,
   SerializedCrdt,
   StorageUpdate,
+  SubscriptionData,
+  SubscriptionDataPlain,
   ThreadData,
   ThreadDataPlain,
   ToImmutable,
   URLSafeString,
+  UserSubscriptionData,
+  UserSubscriptionDataPlain,
 } from "@liveblocks/core";
 import {
   checkBounds,
@@ -49,7 +53,9 @@ import {
   convertToCommentData,
   convertToCommentUserReaction,
   convertToInboxNotificationData,
+  convertToSubscriptionData,
   convertToThreadData,
+  convertToUserSubscriptionData,
   createManagedPool,
   createNotificationSettings,
   LiveObject,
@@ -1384,6 +1390,39 @@ export class Liveblocks {
     return (await res.json()) as Promise<ThreadParticipants>;
   }
 
+  /**
+   * Gets a thread's subscriptions.
+   *
+   * @param params.roomId The room ID to get the thread subscriptions from.
+   * @param params.threadId The thread ID to get the subscriptions from.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns An array of subscriptions.
+   */
+  public async getThreadSubscriptions(
+    params: { roomId: string; threadId: string },
+    options?: RequestOptions
+  ): Promise<{ data: UserSubscriptionData[] }> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#get(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscriptions`,
+      undefined,
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    const { data } = (await res.json()) as {
+      data: UserSubscriptionDataPlain[];
+    };
+
+    return {
+      data: data.map(convertToUserSubscriptionData),
+    };
+  }
+
   /**
    * Gets a thread's comment.
    *
@@ -1612,6 +1651,59 @@ export class Liveblocks {
     return convertToThreadData((await res.json()) as ThreadDataPlain<M>);
   }
 
+  /**
+   * Subscribes a user to a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to subscribe to.
+   * @param params.data.userId The user ID of the user to subscribe to the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns The thread subscription.
+   */
+  public async subscribeToThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<SubscriptionData> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    return convertToSubscriptionData(
+      (await res.json()) as SubscriptionDataPlain
+    );
+  }
+
+  /**
+   * Unsubscribes a user from a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to unsubscribe from.
+   * @param params.data.userId The user ID of the user to unsubscribe from the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   */
+  public async unsubscribeFromThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<void> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/unsubscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+  }
+
   /**
    * Updates the metadata of the specified thread in a room.
    * @param params.roomId The room ID to update the thread in.


Reviews:
user: login: SugarDarius
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:13:31Z
user: login: nvie
body: A quick thought on the naming of these APIs as when I opened this PR, for a moment I read these methods names and my lizard brain thought of an API like:

```ts
liveblocks.subscribeToThread(thread => {
  /* gets called when a thread is updated or something */
});
```

Do you think there is a potential for confusion here?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:26:08Z
user: login: nvie
body: Left only some minor comments, but this is looking great! 👌
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T06:51:24Z
user: login: marcbouchenoire
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:04:37Z
user: login: marcbouchenoire
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:08:42Z
user: login: nvie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T18:06:27Z
user: login: FlowFlorent
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T03:56:17Z
user: login: FlowFlorent
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T03:57:16Z
user: login: marcbouchenoire
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:15:38Z

Review comments:
diff_hunk: @@ -1384,6 +1390,39 @@ export class Liveblocks {
     return (await res.json()) as Promise<ThreadParticipants>;
   }
 
+  /**
+   * Gets a thread's subscriptions.
+   *
+   * @param params.roomId The room ID to get the thread subscriptions from.
+   * @param params.threadId The thread ID to get the subscriptions from.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns An array of subscriptions.
+   */
+  public async getThreadSubscriptions(
+    params: { roomId: string; threadId: string },
+    options?: RequestOptions
+  ): Promise<{ data: UserSubscriptionData[] }> {
path: packages/liveblocks-node/src/client.ts
user: login: nvie
body: Will this method (ever) need pagination?
created_at: 2025-04-15T06:44:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1612,6 +1651,59 @@ export class Liveblocks {
     return convertToThreadData((await res.json()) as ThreadDataPlain<M>);
   }
 
+  /**
+   * Subscribes a user to a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to subscribe to.
+   * @param params.data.userId The user ID of the user to subscribe to the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns The thread subscription.
+   */
+  public async subscribeToThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<SubscriptionData> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    return convertToSubscriptionData(
+      (await res.json()) as SubscriptionDataPlain
+    );
+  }
+
+  /**
+   * Unsubscribes a user from a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to unsubscribe from.
+   * @param params.data.userId The user ID of the user to unsubscribe from the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   */
+  public async unsubscribeFromThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<void> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/unsubscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+  }
path: packages/liveblocks-node/src/client.ts
user: login: nvie
body: It's a good practice to call `res.json()` even if you don't need to return anything in this `void` function.

The reason is that `res.ok` only checks the status header, and it does not start consuming the response body. This might cause the server to hold on to the response body stream longer than needed, because it hasn't been consumed by the client yet.

(I haven't checked the backend implementation, but if you haven't defined a response body in the backend for this endpoint, it might be good to at the very least return a minimal `{"ok":true}` JSON body response there. Ideally, _all_ endpoint responses are JSON.)
created_at: 2025-04-15T06:50:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1384,6 +1390,39 @@ export class Liveblocks {
     return (await res.json()) as Promise<ThreadParticipants>;
   }
 
+  /**
+   * Gets a thread's subscriptions.
+   *
+   * @param params.roomId The room ID to get the thread subscriptions from.
+   * @param params.threadId The thread ID to get the subscriptions from.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns An array of subscriptions.
+   */
+  public async getThreadSubscriptions(
+    params: { roomId: string; threadId: string },
+    options?: RequestOptions
+  ): Promise<{ data: UserSubscriptionData[] }> {
path: packages/liveblocks-node/src/client.ts
user: login: marcbouchenoire
body: We'll likely add it post-launch, and the `{ data }` shape doesn't require breaking changes
created_at: 2025-04-15T15:04:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1612,6 +1651,59 @@ export class Liveblocks {
     return convertToThreadData((await res.json()) as ThreadDataPlain<M>);
   }
 
+  /**
+   * Subscribes a user to a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to subscribe to.
+   * @param params.data.userId The user ID of the user to subscribe to the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns The thread subscription.
+   */
+  public async subscribeToThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<SubscriptionData> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    return convertToSubscriptionData(
+      (await res.json()) as SubscriptionDataPlain
+    );
+  }
+
+  /**
+   * Unsubscribes a user from a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to unsubscribe from.
+   * @param params.data.userId The user ID of the user to unsubscribe from the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   */
+  public async unsubscribeFromThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<void> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/unsubscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+  }
path: packages/liveblocks-node/src/client.ts
user: login: marcbouchenoire
body: The backend implementation doesn't return anything and uses the `204` status code, I mostly followed what we do for "delete"/"remove"-type endpoints so I didn't catch this convention
created_at: 2025-04-15T15:08:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1612,6 +1651,59 @@ export class Liveblocks {
     return convertToThreadData((await res.json()) as ThreadDataPlain<M>);
   }
 
+  /**
+   * Subscribes a user to a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to subscribe to.
+   * @param params.data.userId The user ID of the user to subscribe to the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns The thread subscription.
+   */
+  public async subscribeToThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<SubscriptionData> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    return convertToSubscriptionData(
+      (await res.json()) as SubscriptionDataPlain
+    );
+  }
+
+  /**
+   * Unsubscribes a user from a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to unsubscribe from.
+   * @param params.data.userId The user ID of the user to unsubscribe from the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   */
+  public async unsubscribeFromThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<void> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/unsubscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+  }
path: packages/liveblocks-node/src/client.ts
user: login: nvie
body: Yeah, ideally we stop using empty 204 responses entirely in the backend. It makes it a backward incompatible change if we need to change it. I have been through that a couple times and think at this point that every endpoint should return a JSON object in the body response, no exception. It just makes it a lot easier to extend later. cc @FlowFlorent 
created_at: 2025-04-15T18:06:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1612,6 +1651,59 @@ export class Liveblocks {
     return convertToThreadData((await res.json()) as ThreadDataPlain<M>);
   }
 
+  /**
+   * Subscribes a user to a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to subscribe to.
+   * @param params.data.userId The user ID of the user to subscribe to the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns The thread subscription.
+   */
+  public async subscribeToThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<SubscriptionData> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    return convertToSubscriptionData(
+      (await res.json()) as SubscriptionDataPlain
+    );
+  }
+
+  /**
+   * Unsubscribes a user from a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to unsubscribe from.
+   * @param params.data.userId The user ID of the user to unsubscribe from the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   */
+  public async unsubscribeFromThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<void> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/unsubscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+  }
path: packages/liveblocks-node/src/client.ts
user: login: FlowFlorent
body: Traditionally our DELETEs do not return the resource so that's why it feels ok to use 204. But honestly returning a 200 so that we can extend it later sounds like a lot easier time, on board with that!
created_at: 2025-04-16T03:56:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1612,6 +1651,59 @@ export class Liveblocks {
     return convertToThreadData((await res.json()) as ThreadDataPlain<M>);
   }
 
+  /**
+   * Subscribes a user to a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to subscribe to.
+   * @param params.data.userId The user ID of the user to subscribe to the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   * @returns The thread subscription.
+   */
+  public async subscribeToThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<SubscriptionData> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/subscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+
+    return convertToSubscriptionData(
+      (await res.json()) as SubscriptionDataPlain
+    );
+  }
+
+  /**
+   * Unsubscribes a user from a thread.
+   * @param params.roomId The room ID of the thread.
+   * @param params.threadId The thread ID to unsubscribe from.
+   * @param params.data.userId The user ID of the user to unsubscribe from the thread.
+   * @param options.signal (optional) An abort signal to cancel the request.
+   */
+  public async unsubscribeFromThread(
+    params: { roomId: string; threadId: string; data: { userId: string } },
+    options?: RequestOptions
+  ): Promise<void> {
+    const { roomId, threadId } = params;
+
+    const res = await this.#post(
+      url`/v2/rooms/${roomId}/threads/${threadId}/unsubscribe`,
+      { userId: params.data.userId },
+      options
+    );
+
+    if (!res.ok) {
+      throw await LiveblocksError.from(res);
+    }
+  }
path: packages/liveblocks-node/src/client.ts
user: login: marcbouchenoire
body: Are we okay with the inconsistency between this and all our other 204s? If so I'll update the endpoint and add `await res.json()` here.
created_at: 2025-04-17T07:15:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: nvie
created_at: 2025-04-14T15:28:17Z
body: If you'd stay in the "CRUD" style naming, this issue could be avoided, i.e.

- Get thread subscriptions
- Create thread subscription
- Delete thread subscription

Etc.
author_association: CONTRIBUTOR
user: login: marcbouchenoire
created_at: 2025-04-14T15:50:38Z
body: > Do you think there is a potential for confusion here?

It can be confusing yeah I agree, but on the backend I wasn't a fan of "delete subscription" to mean "unsubscribe" and I think I feel the same for "create subscription", they feel too internal to me. I'm also not a fan of them not matching the client/React APIs. 🤔
author_association: CONTRIBUTOR
user: login: nvie
created_at: 2025-04-15T06:43:46Z
body: Makes sense, as long as it had been considered and the new method names were chosen deliberately, I'm on board with this!
author_association: CONTRIBUTOR
user: login: FlowFlorent
created_at: 2025-04-16T03:59:15Z
body: The more I think about it the more I am leaning towards `createThreadSubscription` and `deleteThreadSubscription`

otherwise I wonder if we should update the endpoints to be:

POST ..../subscribe
POST ..../unsubscribe

And thus it'd make sense to have `unsubscribe` and `subscribe` as method names.

I don't have a strong lean towards one or the other yet, do you have preferences?

@nvie thanks for your suggestion! 
author_association: CONTRIBUTOR
user: login: marcbouchenoire
created_at: 2025-04-16T07:37:00Z
body: > otherwise I wonder if we should update the endpoints to be:
>
> POST ..../subscribe
> POST ..../unsubscribe

@FlowFlorent Not sure I see what you mean, the endpoints are currently `/subscribe` and `/unsubscribe`

My preference is still on `subscribe`/`unsubscribe`, when we'll have generic subscriptions we'll probably go with `createSubscription` and `deleteSubscription` for lack of a better generic name but that means we would have `createSubscription` and `createThreadSubscription` which would just do `createSubscription({ kind: "thread", ... })`, `subscribeToThread` does the same but imo the naming helps it.
author_association: CONTRIBUTOR
user: login: nvie
created_at: 2025-04-16T09:31:05Z
body: @marcbouchenoire This makes a lot of sense, I like the latest naming you went for here 👍 
(And I would also prefer to keep the endpoints named `POST .../subscribe` and `POST .../unsubscribe` in the backend, that's fine. In my initial comment, I was only talking about the JS API, where `something.subscribe(callback)` is a relatively familiar pattern. But with the latest naming update, this is no longer an issue.)
author_association: CONTRIBUTOR