diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js
--- a/keyserver/src/fetchers/message-fetchers.js
+++ b/keyserver/src/fetchers/message-fetchers.js
@@ -720,6 +720,9 @@
     if (row.type === messageTypes.SIDEBAR_SOURCE) {
       const content = JSON.parse(row.content);
       requiredIDs.add(content.sourceMessageID);
+    } else if (row.type === messageTypes.TOGGLE_PIN) {
+      const content = JSON.parse(row.content);
+      requiredIDs.add(content.targetMessageID);
     }
   }
 
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
@@ -1,6 +1,7 @@
 // @flow
 
 import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js';
+import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js';
 import {
   threadHasAdminRole,
   roleIsAdminRole,
@@ -43,7 +44,10 @@
 import { createUpdates } from '../creators/update-creator.js';
 import { dbQuery, SQL } from '../database/database.js';
 import { fetchEntryInfos } from '../fetchers/entry-fetchers.js';
-import { fetchMessageInfos } from '../fetchers/message-fetchers.js';
+import {
+  fetchMessageInfos,
+  fetchMessageInfoByID,
+} from '../fetchers/message-fetchers.js';
 import {
   fetchThreadInfos,
   fetchServerThreadInfos,
@@ -861,16 +865,17 @@
 ): 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 targetMessage = await fetchMessageInfoByID(viewer, messageID);
+  if (!targetMessage) {
+    throw new ServerError('invalid_parameters');
+  }
 
+  const { threadID } = targetMessage;
   const hasPermission = await checkThreadPermission(
     viewer,
     threadID,
     threadPermissions.MANAGE_PINS,
   );
-
   if (!hasPermission) {
     throw new ServerError('invalid_credentials');
   }
@@ -885,6 +890,18 @@
   `;
 
   await dbQuery(togglePinQuery);
+
+  const messageData = {
+    type: messageTypes.TOGGLE_PIN,
+    threadID,
+    targetMessageID: messageID,
+    action,
+    pinnedContent: getPinnedContentFromMessage(targetMessage),
+    creatorID: viewer.userID,
+    time: Date.now(),
+  };
+
+  await createMessages(viewer, [messageData]);
 }
 
 export {
diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js
--- a/lib/shared/message-utils.js
+++ b/lib/shared/message-utils.js
@@ -10,6 +10,7 @@
 import { messageSpecs } from './messages/message-specs.js';
 import { threadIsGroupChat } from './thread-utils.js';
 import { useStringForUser } from '../hooks/ens-cache.js';
+import { contentStringForMediaArray } from '../media/media-utils.js';
 import { userIDsToRelativeUserInfos } from '../selectors/user-selectors.js';
 import { type PlatformDetails, isWebPlatform } from '../types/device-types.js';
 import type { Media } from '../types/media-types.js';
@@ -569,6 +570,20 @@
   return messageSpec.useCreationSideEffectsFunc();
 }
 
+function getPinnedContentFromMessage(targetMessage: RawMessageInfo): string {
+  let pinnedContent;
+  if (
+    targetMessage.type === messageTypes.IMAGES ||
+    targetMessage.type === messageTypes.MULTIMEDIA
+  ) {
+    pinnedContent = contentStringForMediaArray(targetMessage.media);
+  } else {
+    pinnedContent = 'a message';
+  }
+
+  return pinnedContent;
+}
+
 export {
   localIDPrefix,
   messageKey,
@@ -593,4 +608,5 @@
   mergeThreadMessageInfos,
   useMessagePreview,
   useMessageCreationSideEffectsFunc,
+  getPinnedContentFromMessage,
 };
diff --git a/lib/shared/messages/message-specs.js b/lib/shared/messages/message-specs.js
--- a/lib/shared/messages/message-specs.js
+++ b/lib/shared/messages/message-specs.js
@@ -19,6 +19,7 @@
 import { restoreEntryMessageSpec } from './restore-entry-message-spec.js';
 import { sidebarSourceMessageSpec } from './sidebar-source-message-spec.js';
 import { textMessageSpec } from './text-message-spec.js';
+import { togglePinMessageSpec } from './toggle-pin-message-spec.js';
 import { unsupportedMessageSpec } from './unsupported-message-spec.js';
 import { updateRelationshipMessageSpec } from './update-relationship-message-spec.js';
 import { messageTypes, type MessageType } from '../../types/message-types.js';
@@ -47,4 +48,5 @@
   [messageTypes.CREATE_SIDEBAR]: createSidebarMessageSpec,
   [messageTypes.REACTION]: reactionMessageSpec,
   [messageTypes.EDIT_MESSAGE]: editMessageSpec,
+  [messageTypes.TOGGLE_PIN]: togglePinMessageSpec,
 });
diff --git a/lib/shared/messages/toggle-pin-message-spec.js b/lib/shared/messages/toggle-pin-message-spec.js
new file mode 100644
--- /dev/null
+++ b/lib/shared/messages/toggle-pin-message-spec.js
@@ -0,0 +1,158 @@
+// @flow
+
+import invariant from 'invariant';
+
+import type {
+  MessageSpec,
+  RobotextParams,
+  RawMessageInfoFromServerDBRowParams,
+} from './message-spec.js';
+import type { PlatformDetails } from '../../types/device-types';
+import { messageTypes } from '../../types/message-types.js';
+import type { ClientDBMessageInfo } from '../../types/message-types.js';
+import type {
+  TogglePinMessageData,
+  TogglePinMessageInfo,
+  RawTogglePinMessageInfo,
+} from '../../types/messages/toggle-pin.js';
+import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported';
+import type { RelativeUserInfo } from '../../types/user-types.js';
+import { ET, type EntityText } from '../../utils/entity-text.js';
+import { getPinnedContentFromClientDBMessageInfo } from '../../utils/message-ops-utils.js';
+import { getPinnedContentFromMessage } from '../message-utils.js';
+import { hasMinCodeVersion } from '../version-utils.js';
+
+export const togglePinMessageSpec: MessageSpec<
+  TogglePinMessageData,
+  RawTogglePinMessageInfo,
+  TogglePinMessageInfo,
+> = Object.freeze({
+  messageContentForServerDB(
+    data: TogglePinMessageData | RawTogglePinMessageInfo,
+  ): string {
+    return JSON.stringify({
+      action: data.action,
+      threadID: data.threadID,
+      targetMessageID: data.targetMessageID,
+    });
+  },
+
+  messageContentForClientDB(data: RawTogglePinMessageInfo): string {
+    return this.messageContentForServerDB(data);
+  },
+
+  rawMessageInfoFromServerDBRow(
+    row: Object,
+    params: RawMessageInfoFromServerDBRowParams,
+  ): RawTogglePinMessageInfo {
+    const content = JSON.parse(row.content);
+    const { derivedMessages } = params;
+    const targetMessage = derivedMessages.get(content.targetMessageID);
+    invariant(targetMessage, 'targetMessage should be defined');
+
+    return {
+      type: messageTypes.TOGGLE_PIN,
+      id: row.id.toString(),
+      threadID: row.threadID.toString(),
+      targetMessageID: content.targetMessageID.toString(),
+      action: content.action,
+      pinnedContent: getPinnedContentFromMessage(targetMessage),
+      time: row.time,
+      creatorID: row.creatorID.toString(),
+    };
+  },
+
+  rawMessageInfoFromClientDB(
+    clientDBMessageInfo: ClientDBMessageInfo,
+  ): RawTogglePinMessageInfo {
+    invariant(
+      clientDBMessageInfo.content !== undefined &&
+        clientDBMessageInfo.content !== null,
+      'content must be defined for TogglePin',
+    );
+    const content = JSON.parse(clientDBMessageInfo.content);
+    const pinnedContent =
+      getPinnedContentFromClientDBMessageInfo(clientDBMessageInfo);
+
+    const rawTogglePinMessageInfo: RawTogglePinMessageInfo = {
+      type: messageTypes.TOGGLE_PIN,
+      id: clientDBMessageInfo.id,
+      threadID: clientDBMessageInfo.thread,
+      targetMessageID: content.targetMessageID,
+      action: content.action,
+      pinnedContent,
+      time: parseInt(clientDBMessageInfo.time),
+      creatorID: clientDBMessageInfo.user,
+    };
+    return rawTogglePinMessageInfo;
+  },
+
+  createMessageInfo(
+    rawMessageInfo: RawTogglePinMessageInfo,
+    creator: RelativeUserInfo,
+  ): TogglePinMessageInfo {
+    return {
+      type: messageTypes.TOGGLE_PIN,
+      id: rawMessageInfo.id,
+      threadID: rawMessageInfo.threadID,
+      targetMessageID: rawMessageInfo.targetMessageID,
+      action: rawMessageInfo.action,
+      pinnedContent: rawMessageInfo.pinnedContent,
+      creator,
+      time: rawMessageInfo.time,
+    };
+  },
+
+  rawMessageInfoFromMessageData(
+    messageData: TogglePinMessageData,
+    id: ?string,
+  ): RawTogglePinMessageInfo {
+    invariant(id, 'RawTogglePinMessageInfo needs id');
+    return { ...messageData, id };
+  },
+
+  robotext(
+    messageInfo: TogglePinMessageInfo,
+    params: RobotextParams,
+  ): EntityText {
+    const creator = ET.user({ userInfo: messageInfo.creator });
+    const action = messageInfo.action === 'pin' ? 'pinned' : 'unpinned';
+    const pinnedContent = messageInfo.pinnedContent;
+    const preposition = messageInfo.action === 'pin' ? 'in' : 'from';
+    return ET`${creator} ${action} ${pinnedContent} ${preposition} ${ET.thread({
+      display: 'alwaysDisplayShortName',
+      threadID: messageInfo.threadID,
+      threadType: params.threadInfo?.type,
+      parentThreadID: params.threadInfo?.parentThreadID,
+    })}`;
+  },
+
+  shimUnsupportedMessageInfo(
+    rawMessageInfo: RawTogglePinMessageInfo,
+    platformDetails: ?PlatformDetails,
+  ): RawTogglePinMessageInfo | RawUnsupportedMessageInfo {
+    if (hasMinCodeVersion(platformDetails, 209)) {
+      return rawMessageInfo;
+    }
+    const { id } = rawMessageInfo;
+    invariant(id !== null && id !== undefined, 'id should be set on server');
+
+    return {
+      type: messageTypes.UNSUPPORTED,
+      id,
+      threadID: rawMessageInfo.threadID,
+      creatorID: rawMessageInfo.creatorID,
+      time: rawMessageInfo.time,
+      robotext: 'toggled a message pin',
+      unsupportedMessageInfo: rawMessageInfo,
+    };
+  },
+
+  unshimMessageInfo(
+    unwrapped: RawTogglePinMessageInfo,
+  ): RawTogglePinMessageInfo {
+    return unwrapped;
+  },
+
+  generatesNotifs: async () => undefined,
+});
diff --git a/lib/types/message-types.js b/lib/types/message-types.js
--- a/lib/types/message-types.js
+++ b/lib/types/message-types.js
@@ -94,6 +94,11 @@
   TextMessageData,
   TextMessageInfo,
 } from './messages/text.js';
+import type {
+  TogglePinMessageData,
+  TogglePinMessageInfo,
+  RawTogglePinMessageInfo,
+} from './messages/toggle-pin.js';
 import type {
   RawUnsupportedMessageInfo,
   UnsupportedMessageInfo,
@@ -137,6 +142,7 @@
   CREATE_SIDEBAR: 18,
   REACTION: 19,
   EDIT_MESSAGE: 20,
+  TOGGLE_PIN: 21,
 });
 export type MessageType = $Values<typeof messageTypes>;
 export function assertMessageType(ourMessageType: number): MessageType {
@@ -161,7 +167,8 @@
       ourMessageType === 17 ||
       ourMessageType === 18 ||
       ourMessageType === 19 ||
-      ourMessageType === 20,
+      ourMessageType === 20 ||
+      ourMessageType === 21,
     'number is not MessageType enum',
   );
   return ourMessageType;
@@ -253,7 +260,8 @@
   | SidebarSourceMessageData
   | CreateSidebarMessageData
   | ReactionMessageData
-  | EditMessageData;
+  | EditMessageData
+  | TogglePinMessageData;
 
 export type MultimediaMessageData = ImagesMessageData | MediaMessageData;
 
@@ -279,7 +287,8 @@
   | RawRestoreEntryMessageInfo
   | RawUpdateRelationshipMessageInfo
   | RawCreateSidebarMessageInfo
-  | RawUnsupportedMessageInfo;
+  | RawUnsupportedMessageInfo
+  | RawTogglePinMessageInfo;
 export type RawSidebarSourceMessageInfo = {
   ...SidebarSourceMessageData,
   id: string,
@@ -328,7 +337,8 @@
   | RestoreEntryMessageInfo
   | UnsupportedMessageInfo
   | UpdateRelationshipMessageInfo
-  | CreateSidebarMessageInfo;
+  | CreateSidebarMessageInfo
+  | TogglePinMessageInfo;
 export type PreviewableMessageInfo =
   | RobotextMessageInfo
   | MultimediaMessageInfo
diff --git a/lib/types/messages/toggle-pin.js b/lib/types/messages/toggle-pin.js
new file mode 100644
--- /dev/null
+++ b/lib/types/messages/toggle-pin.js
@@ -0,0 +1,29 @@
+// @flow
+
+import type { RelativeUserInfo } from '../user-types.js';
+
+export type TogglePinMessageData = {
+  +type: 21,
+  +threadID: string,
+  +targetMessageID: string,
+  +action: 'pin' | 'unpin',
+  +pinnedContent: string,
+  +creatorID: string,
+  +time: number,
+};
+
+export type RawTogglePinMessageInfo = {
+  ...TogglePinMessageData,
+  +id: string,
+};
+
+export type TogglePinMessageInfo = {
+  +type: 21,
+  +id: string,
+  +threadID: string,
+  +targetMessageID: string,
+  +action: 'pin' | 'unpin',
+  +pinnedContent: string,
+  +creator: RelativeUserInfo,
+  +time: number,
+};
diff --git a/lib/utils/message-ops-utils.js b/lib/utils/message-ops-utils.js
--- a/lib/utils/message-ops-utils.js
+++ b/lib/utils/message-ops-utils.js
@@ -2,6 +2,7 @@
 
 import _keyBy from 'lodash/fp/keyBy.js';
 
+import { contentStringForMediaArray } from '../media/media-utils.js';
 import { messageID } from '../shared/message-utils.js';
 import { messageSpecs } from '../shared/messages/message-specs.js';
 import type {
@@ -243,6 +244,21 @@
   });
 }
 
+function getPinnedContentFromClientDBMessageInfo(
+  clientDBMessageInfo: ClientDBMessageInfo,
+): string {
+  const { media_infos } = clientDBMessageInfo;
+
+  let pinnedContent;
+  if (!media_infos) {
+    pinnedContent = 'a message';
+  } else {
+    const media = translateClientDBMediaInfosToMedia(clientDBMessageInfo);
+    pinnedContent = contentStringForMediaArray(media);
+  }
+  return pinnedContent;
+}
+
 export {
   translateClientDBMediaInfoToImage,
   translateRawMessageInfoToClientDBMessageInfo,
@@ -250,4 +266,5 @@
   translateClientDBMessageInfosToRawMessageInfos,
   convertMessageStoreOperationsToClientDBOperations,
   translateClientDBMediaInfosToMedia,
+  getPinnedContentFromClientDBMessageInfo,
 };