diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js
--- a/keyserver/src/updaters/thread-updaters.js
+++ b/keyserver/src/updaters/thread-updaters.js
@@ -23,6 +23,7 @@
   type UpdateThreadRequest,
   type ServerThreadJoinRequest,
   type ThreadJoinResult,
+  type ToggleMessagePinRequest,
   threadPermissions,
   threadTypes,
 } from 'lib/types/thread-types.js';
@@ -854,6 +855,38 @@
   await createUpdates(updateDatas);
 }
 
+async function toggleMessagePinForThread(
+  viewer: Viewer,
+  request: ToggleMessagePinRequest,
+): Promise<void> {
+  const { messageID, action } = request;
+
+  const threadQuery = SQL`SELECT thread FROM messages WHERE id = ${messageID}`;
+  const [threadResult] = await dbQuery(threadQuery);
+  const threadID = threadResult[0].thread.toString();
+
+  const hasPermission = await checkThreadPermission(
+    viewer,
+    threadID,
+    threadPermissions.MANAGE_PINS,
+  );
+
+  if (!hasPermission) {
+    throw new ServerError('invalid_credentials');
+  }
+
+  const pinnedValue = action === 'pin' ? 1 : 0;
+  const pinTimeValue = action === 'pin' ? Date.now() : null;
+
+  const togglePinQuery = SQL`
+    UPDATE messages
+    SET pinned = ${pinnedValue}, pin_time = ${pinTimeValue}
+    WHERE id = ${messageID} AND thread = ${threadID}
+  `;
+
+  await dbQuery(togglePinQuery);
+}
+
 export {
   updateRole,
   removeMembers,
@@ -861,4 +894,5 @@
   updateThread,
   joinThread,
   updateThreadMembers,
+  toggleMessagePinForThread,
 };
diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js
--- a/lib/types/thread-types.js
+++ b/lib/types/thread-types.js
@@ -488,6 +488,11 @@
   +mostRecentNonLocalMessage: ?string,
 };
 
+export type ToggleMessagePinRequest = {
+  +messageID: string,
+  +action: 'pin' | 'unpin',
+};
+
 // We can show a max of 3 sidebars inline underneath their parent in the chat
 // tab. If there are more, we show a button that opens a modal to see the rest
 export const maxReadSidebars = 3;