diff --git a/keyserver/src/push/encrypted-notif-utils-api.js b/keyserver/src/push/encrypted-notif-utils-api.js
--- a/keyserver/src/push/encrypted-notif-utils-api.js
+++ b/keyserver/src/push/encrypted-notif-utils-api.js
@@ -6,6 +6,7 @@
 
 import { blobServiceUpload } from './utils.js';
 import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js';
+import { getOlmUtility } from '../utils/olm-utils.js';
 
 const encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI = {
   encryptSerializedNotifPayload: async (
@@ -51,6 +52,8 @@
   uploadLargeNotifPayload: blobServiceUpload,
   getNotifByteSize: (serializedPayload: string) =>
     Buffer.byteLength(serializedPayload),
+  getEncryptedNotifHash: (serializedNotification: string) =>
+    getOlmUtility().sha256(serializedNotification),
 };
 
 export default encryptedNotifUtilsAPI;
diff --git a/keyserver/src/push/providers.js b/keyserver/src/push/providers.js
--- a/keyserver/src/push/providers.js
+++ b/keyserver/src/push/providers.js
@@ -113,15 +113,6 @@
   }
 }
 
-function getAPNsNotificationTopic(platformDetails: PlatformDetails): string {
-  if (platformDetails.platform === 'macos') {
-    return 'app.comm.macos';
-  }
-  return platformDetails.codeVersion && platformDetails.codeVersion >= 87
-    ? 'app.comm'
-    : 'org.squadcal.app';
-}
-
 type WebPushConfig = { +publicKey: string, +privateKey: string };
 let cachedWebPushConfig: ?WebPushConfig = null;
 async function getWebPushConfig(): Promise<?WebPushConfig> {
@@ -208,7 +199,6 @@
   getFCMProvider,
   endFirebase,
   endAPNs,
-  getAPNsNotificationTopic,
   getWebPushConfig,
   ensureWebPushInitialized,
   getWNSToken,
diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js
--- a/keyserver/src/push/rescind.js
+++ b/keyserver/src/push/rescind.js
@@ -6,6 +6,7 @@
 import invariant from 'invariant';
 
 import { createAndroidNotificationRescind } from 'lib/push/android-notif-creators.js';
+import { getAPNsNotificationTopic } from 'lib/shared/notif-utils.js';
 import type { PlatformDetails } from 'lib/types/device-types.js';
 import type {
   NotificationTargetDevice,
@@ -20,7 +21,6 @@
 
 import { prepareEncryptedIOSNotificationRescind } from './crypto.js';
 import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js';
-import { getAPNsNotificationTopic } from './providers.js';
 import type { TargetedAPNsNotification } from './types.js';
 import {
   apnPush,
diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js
--- a/keyserver/src/push/send.js
+++ b/keyserver/src/push/send.js
@@ -18,6 +18,7 @@
   createAndroidVisualNotification,
   createAndroidBadgeOnlyNotification,
 } from 'lib/push/android-notif-creators.js';
+import { apnMaxNotificationPayloadByteSize } from 'lib/push/apns-notif-creators.js';
 import {
   type WebNotifInputData,
   webNotifInputDataValidator,
@@ -36,7 +37,10 @@
   sortMessageInfoList,
 } from 'lib/shared/message-utils.js';
 import { messageSpecs } from 'lib/shared/messages/message-specs.js';
-import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.js';
+import {
+  notifTextsForMessageInfo,
+  getAPNsNotificationTopic,
+} from 'lib/shared/notif-utils.js';
 import {
   rawThreadInfoFromServerThreadInfo,
   threadInfoFromRawThreadInfo,
@@ -66,11 +70,9 @@
 
 import { prepareEncryptedAPNsNotifications } from './crypto.js';
 import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js';
-import { getAPNsNotificationTopic } from './providers.js';
 import { rescindPushNotifs } from './rescind.js';
 import type { TargetedAPNsNotification } from './types.js';
 import {
-  apnMaxNotificationPayloadByteSize,
   apnPush,
   fcmPush,
   getUnreadCounts,
diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js
--- a/keyserver/src/push/utils.js
+++ b/keyserver/src/push/utils.js
@@ -8,8 +8,6 @@
 import uuid from 'uuid';
 import webpush from 'web-push';
 
-import { fcmMaxNotificationPayloadByteSize } from 'lib/push/android-notif-creators.js';
-import { wnsMaxNotificationPayloadByteSize } from 'lib/push/wns-notif-creators.js';
 import type { PlatformDetails } from 'lib/types/device-types.js';
 import type {
   TargetedAndroidNotification,
@@ -40,7 +38,6 @@
 const apnTokenInvalidationErrorCode = 410;
 const apnBadRequestErrorCode = 400;
 const apnBadTokenErrorString = 'BadDeviceToken';
-const apnMaxNotificationPayloadByteSize = 4096;
 const webInvalidTokenErrorCodes = [404, 410];
 const wnsInvalidTokenErrorCodes = [404, 410];
 
@@ -446,7 +443,4 @@
   webPush,
   wnsPush,
   getUnreadCounts,
-  apnMaxNotificationPayloadByteSize,
-  fcmMaxNotificationPayloadByteSize,
-  wnsMaxNotificationPayloadByteSize,
 };
diff --git a/lib/push/android-notif-creators.js b/lib/push/android-notif-creators.js
--- a/lib/push/android-notif-creators.js
+++ b/lib/push/android-notif-creators.js
@@ -32,7 +32,7 @@
 
 export const fcmMaxNotificationPayloadByteSize = 4000;
 
-type CommonNativeNotifInputData = $ReadOnly<{
+export type CommonNativeNotifInputData = $ReadOnly<{
   +senderDeviceDescriptor: SenderDeviceDescriptor,
   +notifTexts: ResolvedNotifTexts,
   +newRawMessageInfos: RawMessageInfo[],
@@ -43,16 +43,17 @@
   +platformDetails: PlatformDetails,
 }>;
 
-const commonNativeNotifInputDataValidator = tShape<CommonNativeNotifInputData>({
-  senderDeviceDescriptor: senderDeviceDescriptorValidator,
-  notifTexts: resolvedNotifTextsValidator,
-  newRawMessageInfos: t.list(rawMessageInfoValidator),
-  threadID: tID,
-  collapseKey: t.maybe(t.String),
-  badgeOnly: t.Boolean,
-  unreadCount: t.maybe(t.Number),
-  platformDetails: tPlatformDetails,
-});
+export const commonNativeNotifInputDataValidator: TInterface<CommonNativeNotifInputData> =
+  tShape<CommonNativeNotifInputData>({
+    senderDeviceDescriptor: senderDeviceDescriptorValidator,
+    notifTexts: resolvedNotifTextsValidator,
+    newRawMessageInfos: t.list(rawMessageInfoValidator),
+    threadID: tID,
+    collapseKey: t.maybe(t.String),
+    badgeOnly: t.Boolean,
+    unreadCount: t.maybe(t.Number),
+    platformDetails: tPlatformDetails,
+  });
 
 export type AndroidNotifInputData = {
   ...CommonNativeNotifInputData,
@@ -107,7 +108,7 @@
     },
   };
 
-  if (unreadCount) {
+  if (unreadCount !== undefined && unreadCount !== null) {
     notification.data = {
       ...notification.data,
       badge: unreadCount.toString(),
diff --git a/lib/push/apns-notif-creators.js b/lib/push/apns-notif-creators.js
new file mode 100644
--- /dev/null
+++ b/lib/push/apns-notif-creators.js
@@ -0,0 +1,504 @@
+// @flow
+
+import invariant from 'invariant';
+import t, { type TInterface } from 'tcomb';
+
+import {
+  type CommonNativeNotifInputData,
+  commonNativeNotifInputDataValidator,
+} from './android-notif-creators.js';
+import {
+  prepareEncryptedAPNsVisualNotifications,
+  prepareEncryptedAPNsSilentNotifications,
+} from './crypto.js';
+import { getAPNsNotificationTopic } from '../shared/notif-utils.js';
+import {
+  hasMinCodeVersion,
+  FUTURE_CODE_VERSION,
+} from '../shared/version-utils.js';
+import type { PlatformDetails } from '../types/device-types.js';
+import { messageTypes } from '../types/message-types-enum.js';
+import {
+  type NotificationTargetDevice,
+  type EncryptedNotifUtilsAPI,
+  type TargetedAPNsNotification,
+  type APNsVisualNotification,
+  type APNsNotificationHeaders,
+  type SenderDeviceDescriptor,
+} from '../types/notif-types.js';
+import { tShape } from '../utils/validation-utils.js';
+
+export const apnMaxNotificationPayloadByteSize = 4096;
+
+export type APNsNotifInputData = {
+  ...CommonNativeNotifInputData,
+  +badgeOnly: boolean,
+  +uniqueID: string,
+};
+
+export const apnsNotifInputDataValidator: TInterface<APNsNotifInputData> =
+  tShape<APNsNotifInputData>({
+    ...commonNativeNotifInputDataValidator.meta.props,
+    badgeOnly: t.Boolean,
+    uniqueID: t.String,
+  });
+
+async function createAPNsVisualNotification(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  inputData: APNsNotifInputData,
+  devices: $ReadOnlyArray<NotificationTargetDevice>,
+): Promise<$ReadOnlyArray<TargetedAPNsNotification>> {
+  const {
+    senderDeviceDescriptor,
+    notifTexts,
+    newRawMessageInfos,
+    threadID,
+    collapseKey,
+    badgeOnly,
+    unreadCount,
+    platformDetails,
+    uniqueID,
+  } = inputData;
+
+  const canDecryptNonCollapsibleTextIOSNotifs = hasMinCodeVersion(
+    platformDetails,
+    { native: 222 },
+  );
+
+  const isNonCollapsibleTextNotification =
+    newRawMessageInfos.every(
+      newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT,
+    ) && !collapseKey;
+
+  const canDecryptAllIOSNotifs = hasMinCodeVersion(platformDetails, {
+    native: 267,
+  });
+
+  const canDecryptIOSNotif =
+    platformDetails.platform === 'ios' &&
+    (canDecryptAllIOSNotifs ||
+      (isNonCollapsibleTextNotification &&
+        canDecryptNonCollapsibleTextIOSNotifs));
+
+  const canDecryptMacOSNotifs =
+    platformDetails.platform === 'macos' &&
+    hasMinCodeVersion(platformDetails, {
+      web: 47,
+      majorDesktop: 9,
+    });
+
+  let apsDictionary = {
+    'thread-id': threadID,
+  };
+  if (unreadCount !== undefined && unreadCount !== null) {
+    apsDictionary = {
+      ...apsDictionary,
+      badge: unreadCount,
+    };
+  }
+
+  const { merged, ...rest } = notifTexts;
+  // We don't include alert's body on macos because we
+  // handle displaying the notification ourselves and
+  // we don't want macOS to display it automatically.
+  if (!badgeOnly && platformDetails.platform !== 'macos') {
+    apsDictionary = {
+      ...apsDictionary,
+      alert: merged,
+      sound: 'default',
+    };
+  }
+
+  if (hasMinCodeVersion(platformDetails, { native: 198 })) {
+    apsDictionary = {
+      ...apsDictionary,
+      'mutable-content': 1,
+    };
+  }
+
+  let notificationPayload = {
+    ...rest,
+    id: uniqueID,
+    threadID,
+  };
+
+  let notificationHeaders: APNsNotificationHeaders = {
+    'apns-topic': getAPNsNotificationTopic(platformDetails),
+    'apns-id': uniqueID,
+    'apns-push-type': 'alert',
+  };
+
+  if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) {
+    notificationPayload = {
+      ...notificationPayload,
+      collapseID: collapseKey,
+    };
+  } else if (collapseKey) {
+    notificationHeaders = {
+      ...notificationHeaders,
+      'apns-collapse-id': collapseKey,
+    };
+  }
+
+  const notification = {
+    ...notificationPayload,
+    headers: notificationHeaders,
+    aps: apsDictionary,
+  };
+
+  const messageInfos = JSON.stringify(newRawMessageInfos);
+  const copyWithMessageInfos = {
+    ...notification,
+    messageInfos,
+  };
+
+  const notificationSizeValidator = (notif: APNsVisualNotification) => {
+    const { headers, ...notifSansHeaders } = notif;
+    return (
+      encryptedNotifUtilsAPI.getNotifByteSize(
+        JSON.stringify(notifSansHeaders),
+      ) <= apnMaxNotificationPayloadByteSize
+    );
+  };
+
+  const serializeAPNsNotif = (notif: APNsVisualNotification) => {
+    const { headers, ...notifSansHeaders } = notif;
+    return JSON.stringify(notifSansHeaders);
+  };
+
+  const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs;
+  if (!shouldBeEncrypted) {
+    const notificationToSend = notificationSizeValidator(copyWithMessageInfos)
+      ? copyWithMessageInfos
+      : notification;
+    return devices.map(({ deliveryID }) => ({
+      notification: notificationToSend,
+      deliveryID,
+    }));
+  }
+
+  // The `messageInfos` field in notification payload is
+  // not used on MacOS so we can return early.
+  if (platformDetails.platform === 'macos') {
+    const macOSNotifsWithoutMessageInfos =
+      await prepareEncryptedAPNsVisualNotifications(
+        encryptedNotifUtilsAPI,
+        senderDeviceDescriptor,
+        devices,
+        notification,
+        platformDetails.codeVersion,
+      );
+    return macOSNotifsWithoutMessageInfos.map(
+      ({ notification: notif, deliveryID }) => ({
+        notification: notif,
+        deliveryID,
+      }),
+    );
+  }
+
+  const notifsWithMessageInfos = await prepareEncryptedAPNsVisualNotifications(
+    encryptedNotifUtilsAPI,
+    senderDeviceDescriptor,
+    devices,
+    copyWithMessageInfos,
+    platformDetails.codeVersion,
+    notificationSizeValidator,
+  );
+
+  const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos
+    .filter(({ payloadSizeExceeded }) => payloadSizeExceeded)
+    .map(({ cryptoID, deliveryID }) => ({
+      cryptoID,
+      deliveryID,
+    }));
+
+  if (devicesWithExcessiveSizeNoHolders.length === 0) {
+    return notifsWithMessageInfos.map(
+      ({
+        notification: notif,
+        deliveryID,
+        encryptedPayloadHash,
+        encryptionOrder,
+      }) => ({
+        notification: notif,
+        deliveryID,
+        encryptedPayloadHash,
+        encryptionOrder,
+      }),
+    );
+  }
+
+  const canQueryBlobService = hasMinCodeVersion(platformDetails, {
+    native: 331,
+  });
+
+  let blobHash, blobHolders, encryptionKey, blobUploadError;
+  if (canQueryBlobService) {
+    ({ blobHash, blobHolders, encryptionKey, blobUploadError } =
+      await encryptedNotifUtilsAPI.uploadLargeNotifPayload(
+        serializeAPNsNotif(copyWithMessageInfos),
+        devicesWithExcessiveSizeNoHolders.length,
+      ));
+  }
+
+  if (blobUploadError) {
+    console.warn(
+      `Failed to upload payload of notification: ${uniqueID} ` +
+        `due to error: ${blobUploadError}`,
+    );
+  }
+
+  let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders;
+  let notificationWithBlobMetadata = notification;
+  if (
+    blobHash &&
+    encryptionKey &&
+    blobHolders &&
+    blobHolders.length === devicesWithExcessiveSize.length
+  ) {
+    notificationWithBlobMetadata = {
+      ...notification,
+      blobHash,
+      encryptionKey,
+    };
+    devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({
+      ...devicesWithExcessiveSize[idx],
+      blobHolder: holder,
+    }));
+  }
+
+  const notifsWithoutMessageInfos =
+    await prepareEncryptedAPNsVisualNotifications(
+      encryptedNotifUtilsAPI,
+      senderDeviceDescriptor,
+      devicesWithExcessiveSize,
+      notificationWithBlobMetadata,
+      platformDetails.codeVersion,
+    );
+
+  const targetedNotifsWithMessageInfos = notifsWithMessageInfos
+    .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded)
+    .map(
+      ({
+        notification: notif,
+        deliveryID,
+        encryptedPayloadHash,
+        encryptionOrder,
+      }) => ({
+        notification: notif,
+        deliveryID,
+        encryptedPayloadHash,
+        encryptionOrder,
+      }),
+    );
+
+  const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map(
+    ({
+      notification: notif,
+      deliveryID,
+      encryptedPayloadHash,
+      encryptionOrder,
+    }) => ({
+      notification: notif,
+      deliveryID,
+      encryptedPayloadHash,
+      encryptionOrder,
+    }),
+  );
+
+  return [
+    ...targetedNotifsWithMessageInfos,
+    ...targetedNotifsWithoutMessageInfos,
+  ];
+}
+
+type APNsNotificationRescindInputData = {
+  +senderDeviceDescriptor: SenderDeviceDescriptor,
+  +rescindID?: string,
+  +badge?: number,
+  +threadID: string,
+  +platformDetails: PlatformDetails,
+};
+
+async function createAPNsNotificationRescind(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  inputData: APNsNotificationRescindInputData,
+  devices: $ReadOnlyArray<NotificationTargetDevice>,
+): Promise<$ReadOnlyArray<TargetedAPNsNotification>> {
+  const {
+    badge,
+    rescindID,
+    threadID,
+    platformDetails,
+    senderDeviceDescriptor,
+  } = inputData;
+
+  invariant(
+    (rescindID && badge !== null && badge !== undefined) ||
+      hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }),
+    'thick thread rescind not support for this client version',
+  );
+
+  const apnsTopic = getAPNsNotificationTopic(platformDetails);
+  let notification;
+
+  if (
+    rescindID &&
+    badge !== null &&
+    badge !== undefined &&
+    hasMinCodeVersion(platformDetails, { native: 198 })
+  ) {
+    notification = {
+      headers: {
+        'apns-topic': apnsTopic,
+        'apns-push-type': 'alert',
+      },
+      aps: {
+        'mutable-content': 1,
+        'badge': badge,
+      },
+      threadID,
+      notificationId: rescindID,
+      backgroundNotifType: 'CLEAR',
+      setUnreadStatus: true,
+    };
+  } else if (rescindID && badge !== null && badge !== undefined) {
+    notification = {
+      headers: {
+        'apns-topic': apnsTopic,
+        'apns-push-type': 'background',
+        'apns-priority': 5,
+      },
+      aps: {
+        'mutable-content': 1,
+        'badge': badge,
+      },
+      threadID,
+      notificationId: rescindID,
+      backgroundNotifType: 'CLEAR',
+      setUnreadStatus: true,
+    };
+  } else {
+    notification = {
+      headers: {
+        'apns-topic': apnsTopic,
+        'apns-push-type': 'alert',
+      },
+      aps: {
+        'mutable-content': 1,
+      },
+      threadID,
+      backgroundNotifType: 'CLEAR',
+      setUnreadStatus: true,
+    };
+  }
+
+  const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 233 });
+  if (!shouldBeEncrypted) {
+    return devices.map(({ deliveryID }) => ({
+      notification,
+      deliveryID,
+    }));
+  }
+
+  const notifications = await prepareEncryptedAPNsSilentNotifications(
+    encryptedNotifUtilsAPI,
+    senderDeviceDescriptor,
+    devices,
+    notification,
+    platformDetails.codeVersion,
+  );
+
+  return notifications.map(({ deliveryID, notification: notif }) => ({
+    deliveryID,
+    notification: notif,
+  }));
+}
+
+type APNsBadgeOnlyNotificationInputData = {
+  +senderDeviceDescriptor: SenderDeviceDescriptor,
+  +badge?: number,
+  +threadID?: string,
+  +platformDetails: PlatformDetails,
+};
+
+async function createAPNsBadgeOnlyNotification(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  inputData: APNsBadgeOnlyNotificationInputData,
+  devices: $ReadOnlyArray<NotificationTargetDevice>,
+): Promise<$ReadOnlyArray<TargetedAPNsNotification>> {
+  const { senderDeviceDescriptor, platformDetails, threadID, badge } =
+    inputData;
+  invariant(
+    (!threadID && badge !== undefined && badge !== null) ||
+      hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }),
+    'thick thread badge updates not support for this client version',
+  );
+
+  const shouldBeEncrypted = hasMinCodeVersion(platformDetails, {
+    native: 222,
+    web: 47,
+    majorDesktop: 9,
+  });
+
+  const headers: APNsNotificationHeaders = {
+    'apns-topic': getAPNsNotificationTopic(platformDetails),
+    'apns-push-type': 'alert',
+  };
+
+  let notification;
+  if (shouldBeEncrypted && threadID) {
+    notification = {
+      headers,
+      threadID,
+      aps: {
+        'mutable-content': 1,
+      },
+    };
+  } else if (shouldBeEncrypted && badge !== undefined && badge !== null) {
+    notification = {
+      headers,
+      aps: {
+        'badge': badge,
+        'mutable-content': 1,
+      },
+    };
+  } else {
+    invariant(
+      badge !== null && badge !== undefined,
+      'badge update must contain either badge count or threadID',
+    );
+    notification = {
+      headers,
+      aps: {
+        badge,
+      },
+    };
+  }
+
+  if (!shouldBeEncrypted) {
+    return devices.map(({ deliveryID }) => ({
+      deliveryID,
+      notification,
+    }));
+  }
+
+  const notifications = await prepareEncryptedAPNsSilentNotifications(
+    encryptedNotifUtilsAPI,
+    senderDeviceDescriptor,
+    devices,
+    notification,
+    platformDetails.codeVersion,
+  );
+
+  return notifications.map(({ deliveryID, notification: notif }) => ({
+    deliveryID,
+    notification: notif,
+  }));
+}
+
+export {
+  createAPNsBadgeOnlyNotification,
+  createAPNsNotificationRescind,
+  createAPNsVisualNotification,
+};
diff --git a/lib/push/crypto.js b/lib/push/crypto.js
--- a/lib/push/crypto.js
+++ b/lib/push/crypto.js
@@ -1,5 +1,7 @@
 // @flow
 
+import invariant from 'invariant';
+
 import type {
   PlainTextWebNotification,
   PlainTextWebNotificationPayload,
@@ -13,11 +15,14 @@
   NotificationTargetDevice,
   SenderDeviceDescriptor,
   EncryptedNotifUtilsAPI,
+  APNsVisualNotification,
+  APNsNotificationRescind,
+  APNsBadgeOnlyNotification,
 } from '../types/notif-types.js';
 
 async function encryptAndroidNotificationPayload<T>(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
-  cookieID: string,
+  cryptoID: string,
   senderDeviceDescriptor: SenderDeviceDescriptor,
   unencryptedPayload: T,
   payloadSizeValidator?: (
@@ -65,7 +70,7 @@
       sizeLimitViolated: dbPersistConditionViolated,
       encryptionOrder,
     } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload(
-      cookieID,
+      cryptoID,
       unencryptedSerializedPayload,
       dbPersistCondition,
     );
@@ -94,10 +99,177 @@
   }
 }
 
+async function encryptAPNsVisualNotification(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  cryptoID: string,
+  senderDeviceDescriptor: SenderDeviceDescriptor,
+  notification: APNsVisualNotification,
+  notificationSizeValidator?: APNsVisualNotification => boolean,
+  codeVersion?: ?number,
+  blobHolder?: ?string,
+): Promise<{
+  +notification: APNsVisualNotification,
+  +payloadSizeExceeded: boolean,
+  +encryptedPayloadHash?: string,
+  +encryptionOrder?: number,
+}> {
+  const {
+    id,
+    headers,
+    aps: { badge, alert, sound },
+    ...rest
+  } = notification;
+
+  invariant(
+    !headers['apns-collapse-id'],
+    `Collapse ID can't be directly stored in APNsVisualNotification object due ` +
+      `to security reasons. Please put it in payload property`,
+  );
+
+  let unencryptedPayload = {
+    ...rest,
+    aps: { sound },
+    merged: alert,
+    badge,
+  };
+
+  if (blobHolder) {
+    unencryptedPayload = { ...unencryptedPayload, blobHolder };
+  }
+
+  try {
+    const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload);
+
+    let encryptedNotifAps = { 'mutable-content': 1 };
+    if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) {
+      encryptedNotifAps = {
+        ...encryptedNotifAps,
+        alert: { body: 'ENCRYPTED' },
+      };
+    }
+
+    let dbPersistCondition;
+    if (notificationSizeValidator) {
+      dbPersistCondition = (encryptedPayload: string, type: '0' | '1') =>
+        notificationSizeValidator({
+          ...senderDeviceDescriptor,
+          id,
+          headers,
+          encryptedPayload,
+          type,
+          aps: encryptedNotifAps,
+        });
+    }
+
+    const {
+      encryptedData: serializedPayload,
+      sizeLimitViolated: dbPersistConditionViolated,
+      encryptionOrder,
+    } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload(
+      cryptoID,
+      unencryptedSerializedPayload,
+      dbPersistCondition,
+    );
+
+    const encryptedPayloadHash = encryptedNotifUtilsAPI.getEncryptedNotifHash(
+      serializedPayload.body,
+    );
+
+    return {
+      notification: {
+        ...senderDeviceDescriptor,
+        id,
+        headers,
+        encryptedPayload: serializedPayload.body,
+        type: serializedPayload.type ? '1' : '0',
+        aps: encryptedNotifAps,
+      },
+      payloadSizeExceeded: !!dbPersistConditionViolated,
+      encryptedPayloadHash,
+      encryptionOrder,
+    };
+  } catch (e) {
+    console.log('Notification encryption failed: ' + e);
+    const unencryptedNotification = { ...notification, encryptionFailed: '1' };
+    return {
+      notification: unencryptedNotification,
+      payloadSizeExceeded: notificationSizeValidator
+        ? notificationSizeValidator(unencryptedNotification)
+        : false,
+    };
+  }
+}
+
+async function encryptAPNsSilentNotification(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  cryptoID: string,
+  senderDeviceDescriptor: SenderDeviceDescriptor,
+  notification: APNsNotificationRescind | APNsBadgeOnlyNotification,
+  codeVersion?: ?number,
+): Promise<{
+  +notification: APNsNotificationRescind | APNsBadgeOnlyNotification,
+  +encryptedPayloadHash?: string,
+  +encryptionOrder?: number,
+}> {
+  const {
+    headers,
+    aps: { badge },
+    ...rest
+  } = notification;
+
+  let unencryptedPayload = {
+    ...rest,
+  };
+
+  if (badge !== null && badge !== undefined) {
+    unencryptedPayload = { ...unencryptedPayload, badge, aps: {} };
+  }
+
+  try {
+    const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload);
+
+    let encryptedNotifAps = { 'mutable-content': 1 };
+    if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) {
+      encryptedNotifAps = {
+        ...encryptedNotifAps,
+        alert: { body: 'ENCRYPTED' },
+      };
+    }
+
+    const { encryptedData: serializedPayload, encryptionOrder } =
+      await encryptedNotifUtilsAPI.encryptSerializedNotifPayload(
+        cryptoID,
+        unencryptedSerializedPayload,
+      );
+
+    const encryptedPayloadHash = encryptedNotifUtilsAPI.getEncryptedNotifHash(
+      serializedPayload.body,
+    );
+
+    return {
+      notification: {
+        ...senderDeviceDescriptor,
+        headers,
+        encryptedPayload: serializedPayload.body,
+        type: serializedPayload.type ? '1' : '0',
+        aps: encryptedNotifAps,
+      },
+      encryptedPayloadHash,
+      encryptionOrder,
+    };
+  } catch (e) {
+    console.log('Notification encryption failed: ' + e);
+    const unencryptedNotification = { ...notification, encryptionFailed: '1' };
+    return {
+      notification: unencryptedNotification,
+    };
+  }
+}
+
 async function encryptAndroidVisualNotification(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
   senderDeviceDescriptor: SenderDeviceDescriptor,
-  cookieID: string,
+  cryptoID: string,
   notification: AndroidVisualNotification,
   notificationSizeValidator?: AndroidVisualNotification => boolean,
   blobHolder?: ?string,
@@ -137,7 +309,7 @@
   const { resultPayload, payloadSizeExceeded, encryptionOrder } =
     await encryptAndroidNotificationPayload(
       encryptedNotifUtilsAPI,
-      cookieID,
+      cryptoID,
       senderDeviceDescriptor,
       unencryptedPayload,
       payloadSizeValidator,
@@ -156,7 +328,7 @@
 
 async function encryptAndroidSilentNotification(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
-  cookieID: string,
+  cryptoID: string,
   senderDeviceDescriptor: SenderDeviceDescriptor,
   notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification,
 ): Promise<AndroidNotificationRescind | AndroidBadgeOnlyNotification> {
@@ -166,7 +338,7 @@
   const { ...unencryptedPayload } = notification.data;
   const { resultPayload } = await encryptAndroidNotificationPayload(
     encryptedNotifUtilsAPI,
-    cookieID,
+    cryptoID,
     senderDeviceDescriptor,
     unencryptedPayload,
   );
@@ -191,7 +363,7 @@
 
 async function encryptBasicPayload<T>(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
-  cookieID: string,
+  cryptoID: string,
   senderDeviceDescriptor: SenderDeviceDescriptor,
   basicPayload: T,
 ): Promise<
@@ -212,7 +384,7 @@
   try {
     const { encryptedData: serializedPayload, encryptionOrder } =
       await encryptedNotifUtilsAPI.encryptSerializedNotifPayload(
-        cookieID,
+        cryptoID,
         unencryptedSerializedPayload,
       );
 
@@ -233,7 +405,7 @@
 
 async function encryptWebNotification(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
-  cookieID: string,
+  cryptoID: string,
   senderDeviceDescriptor: SenderDeviceDescriptor,
   notification: PlainTextWebNotification,
 ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> {
@@ -241,7 +413,7 @@
   const { encryptionOrder, ...encryptionResult } =
     await encryptBasicPayload<PlainTextWebNotificationPayload>(
       encryptedNotifUtilsAPI,
-      cookieID,
+      cryptoID,
       senderDeviceDescriptor,
       payloadSansId,
     );
@@ -254,14 +426,14 @@
 
 async function encryptWNSNotification(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
-  cookieID: string,
+  cryptoID: string,
   senderDeviceDescriptor: SenderDeviceDescriptor,
   notification: PlainTextWNSNotification,
 ): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> {
   const { encryptionOrder, ...encryptionResult } =
     await encryptBasicPayload<PlainTextWNSNotification>(
       encryptedNotifUtilsAPI,
-      cookieID,
+      cryptoID,
       senderDeviceDescriptor,
       notification,
     );
@@ -271,6 +443,66 @@
   };
 }
 
+function prepareEncryptedAPNsVisualNotifications(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  senderDeviceDescriptor: SenderDeviceDescriptor,
+  devices: $ReadOnlyArray<NotificationTargetDevice>,
+  notification: APNsVisualNotification,
+  codeVersion?: ?number,
+  notificationSizeValidator?: APNsVisualNotification => boolean,
+): Promise<
+  $ReadOnlyArray<{
+    +cryptoID: string,
+    +deliveryID: string,
+    +notification: APNsVisualNotification,
+    +payloadSizeExceeded: boolean,
+    +encryptedPayloadHash?: string,
+    +encryptionOrder?: number,
+  }>,
+> {
+  const notificationPromises = devices.map(
+    async ({ cryptoID, deliveryID, blobHolder }) => {
+      const notif = await encryptAPNsVisualNotification(
+        encryptedNotifUtilsAPI,
+        cryptoID,
+        senderDeviceDescriptor,
+        notification,
+        notificationSizeValidator,
+        codeVersion,
+        blobHolder,
+      );
+      return { cryptoID, deliveryID, ...notif };
+    },
+  );
+  return Promise.all(notificationPromises);
+}
+
+function prepareEncryptedAPNsSilentNotifications(
+  encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
+  senderDeviceDescriptor: SenderDeviceDescriptor,
+  devices: $ReadOnlyArray<NotificationTargetDevice>,
+  notification: APNsNotificationRescind | APNsBadgeOnlyNotification,
+  codeVersion?: ?number,
+): Promise<
+  $ReadOnlyArray<{
+    +cryptoID: string,
+    +deliveryID: string,
+    +notification: APNsNotificationRescind | APNsBadgeOnlyNotification,
+  }>,
+> {
+  const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => {
+    const { notification: notif } = await encryptAPNsSilentNotification(
+      encryptedNotifUtilsAPI,
+      cryptoID,
+      senderDeviceDescriptor,
+      notification,
+      codeVersion,
+    );
+    return { cryptoID, deliveryID, notification: notif };
+  });
+  return Promise.all(notificationPromises);
+}
+
 function prepareEncryptedAndroidVisualNotifications(
   encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI,
   senderDeviceDescriptor: SenderDeviceDescriptor,
@@ -378,6 +610,8 @@
 }
 
 export {
+  prepareEncryptedAPNsVisualNotifications,
+  prepareEncryptedAPNsSilentNotifications,
   prepareEncryptedAndroidVisualNotifications,
   prepareEncryptedAndroidSilentNotifications,
   prepareEncryptedWebNotifications,
diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js
--- a/lib/shared/notif-utils.js
+++ b/lib/shared/notif-utils.js
@@ -7,6 +7,7 @@
 import type { NotificationTextsParams } from './messages/message-spec.js';
 import { messageSpecs } from './messages/message-specs.js';
 import { threadNoun } from './thread-utils.js';
+import { type PlatformDetails } from '../types/device-types.js';
 import { type MessageType, messageTypes } from '../types/message-types-enum.js';
 import {
   type MessageData,
@@ -18,7 +19,11 @@
 import type { CreateSidebarMessageInfo } from '../types/messages/create-sidebar.js';
 import type { TextMessageInfo } from '../types/messages/text.js';
 import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
-import type { NotifTexts, ResolvedNotifTexts } from '../types/notif-types.js';
+import type {
+  NotifTexts,
+  ResolvedNotifTexts,
+  APNsNotificationTopic,
+} from '../types/notif-types.js';
 import { type ThreadType, threadTypes } from '../types/thread-types-enum.js';
 import type { RelativeUserInfo, UserInfo } from '../types/user-types.js';
 import { prettyDate } from '../utils/date-utils.js';
@@ -321,6 +326,17 @@
   return { body: merged, title };
 }
 
+function getAPNsNotificationTopic(
+  platformDetails: PlatformDetails,
+): APNsNotificationTopic {
+  if (platformDetails.platform === 'macos') {
+    return 'app.comm.macos';
+  }
+  return platformDetails.codeVersion && platformDetails.codeVersion >= 87
+    ? 'app.comm'
+    : 'org.squadcal.app';
+}
+
 export {
   notifRobotextForMessageInfo,
   notifTextsForMessageInfo,
@@ -329,4 +345,5 @@
   notifTextsForSidebarCreation,
   getNotifCollapseKey,
   mergePrefixIntoBody,
+  getAPNsNotificationTopic,
 };
diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js
--- a/lib/types/notif-types.js
+++ b/lib/types/notif-types.js
@@ -37,6 +37,7 @@
     tShape({ senderDeviceID: t.String }),
   ]);
 
+// Web notifs types
 export type PlainTextWebNotificationPayload = {
   +body: string,
   +prefix?: string,
@@ -62,6 +63,7 @@
   | PlainTextWebNotification
   | EncryptedWebNotification;
 
+// WNS notifs types
 export type PlainTextWNSNotification = {
   +body: string,
   +prefix?: string,
@@ -81,13 +83,14 @@
   | PlainTextWNSNotification
   | EncryptedWNSNotification;
 
+// Android notifs types
 export type AndroidVisualNotificationPayloadBase = $ReadOnly<{
   +badge?: string,
   +body: string,
   +title: string,
   +prefix?: string,
   +threadID: string,
-  +collapseKey?: string,
+  +collapseID?: string,
   +badgeOnly?: '0',
   +encryptionFailed?: '1',
 }>;
@@ -184,6 +187,156 @@
       +priority: 'normal',
     };
 
+// APNs notifs types
+export type APNsNotificationTopic =
+  | 'app.comm.macos'
+  | 'app.comm'
+  | 'org.squadcal.app';
+
+export type APNsNotificationHeaders = {
+  +'apns-priority'?: 1 | 5 | 10,
+  +'apns-id'?: string,
+  +'apns-expiration'?: number,
+  +'apns-topic': APNsNotificationTopic,
+  +'apns-collapse-id'?: string,
+  +'apns-push-type': 'background' | 'alert' | 'voip',
+};
+
+export type EncryptedAPNsSilentNotification = $ReadOnly<{
+  ...SenderDeviceDescriptor,
+  +headers: APNsNotificationHeaders,
+  +encryptedPayload: string,
+  +type: '1' | '0',
+  +aps: { +'mutable-content': number, +'alert'?: { body: 'ENCRYPTED' } },
+}>;
+
+export type EncryptedAPNsVisualNotification = $ReadOnly<{
+  ...EncryptedAPNsSilentNotification,
+  +id: string,
+}>;
+
+type APNsVisualNotificationPayloadBase = {
+  +aps: {
+    +'badge'?: string | number,
+    +'alert'?: string | { +body?: string, ... },
+    +'thread-id': string,
+    +'mutable-content'?: number,
+    +'sound'?: string,
+  },
+  +body: string,
+  +title: string,
+  +prefix?: string,
+  +threadID: string,
+  +collapseID?: string,
+  +encryptionFailed?: '1',
+};
+
+type APNsSmallVisualNotificationPayload = $ReadOnly<{
+  ...APNsVisualNotificationPayloadBase,
+  +messageInfos?: string,
+}>;
+
+type APNsLargeVisualNotificationPayload = $ReadOnly<{
+  ...APNsVisualNotificationPayloadBase,
+  +blobHash: string,
+  +encryptionKey: string,
+}>;
+
+export type APNsVisualNotification =
+  | $ReadOnly<{
+      +headers: APNsNotificationHeaders,
+      +id: string,
+      ...
+        | APNsSmallVisualNotificationPayload
+        | APNsLargeVisualNotificationPayload,
+    }>
+  | EncryptedAPNsVisualNotification;
+
+type APNsLegacyRescindPayload = {
+  +backgroundNotifType: 'CLEAR',
+  +notificationId: string,
+  +setUnreadStatus: true,
+  +threadID: string,
+  +aps: {
+    +'badge': string | number,
+    +'content-available': number,
+  },
+};
+
+type APNsKeyserverRescindPayload = {
+  +backgroundNotifType: 'CLEAR',
+  +notificationId: string,
+  +setUnreadStatus: true,
+  +threadID: string,
+  +aps: {
+    +'badge': string | number,
+    +'mutable-content': number,
+  },
+};
+
+type APNsThickThreadRescindPayload = {
+  +backgroundNotifType: 'CLEAR',
+  +setUnreadStatus: true,
+  +threadID: string,
+  +aps: {
+    +'mutable-content': number,
+  },
+};
+
+export type APNsNotificationRescind =
+  | $ReadOnly<{
+      +headers: APNsNotificationHeaders,
+      +encryptionFailed?: '1',
+      ...
+        | APNsLegacyRescindPayload
+        | APNsKeyserverRescindPayload
+        | APNsThickThreadRescindPayload,
+    }>
+  | EncryptedAPNsSilentNotification;
+
+type APNsLegacyBadgeOnlyNotification = {
+  +aps: {
+    +badge: string | number,
+  },
+};
+
+type APNsKeyserverBadgeOnlyNotification = {
+  +aps: {
+    +'badge': string | number,
+    +'mutable-content': number,
+  },
+};
+
+type APNsThickThreadBadgeOnlyNotification = {
+  +aps: {
+    +'mutable-content': number,
+  },
+  +threadID: string,
+};
+
+export type APNsBadgeOnlyNotification =
+  | $ReadOnly<{
+      +headers: APNsNotificationHeaders,
+      +encryptionFailed?: '1',
+      ...
+        | APNsLegacyBadgeOnlyNotification
+        | APNsKeyserverBadgeOnlyNotification
+        | APNsThickThreadBadgeOnlyNotification,
+    }>
+  | EncryptedAPNsSilentNotification;
+
+export type APNsNotification =
+  | APNsVisualNotification
+  | APNsNotificationRescind
+  | APNsBadgeOnlyNotification;
+
+export type TargetedAPNsNotification = {
+  +notification: APNsNotification,
+  +deliveryID: string,
+  +encryptedPayloadHash?: string,
+  +encryptionOrder?: number,
+};
+
 export type TargetedAndroidNotification = $ReadOnly<{
   ...AndroidNotificationWithPriority,
   +deliveryID: string,
@@ -233,4 +386,5 @@
     | { +blobUploadError: string },
   >,
   +getNotifByteSize: (serializedNotification: string) => number,
+  +getEncryptedNotifHash: (serializedNotification: string) => string,
 };