Web Push on Safari, iOS and iPadOS

Apple shipped standards-based Web Push in two stages: Safari 16 on macOS Ventura in 2022, then Safari 16.4 on iOS and iPadOS 16.4 in March 2023. Both speak the same RFC 8030 transport, RFC 8291 aes128gcm encryption and RFC 8292 VAPID authentication as Chromium and Gecko, so a correctly built sender needs no Apple-specific branch at the transport layer.

Everything around the send is Apple-specific. On iPhone and iPad the site must be installed to the Home Screen before a subscription can exist at all. Notification.requestPermission() must run inside a genuine user gesture. Apple’s push service validates the VAPID JWT more strictly than FCM does, silent push is simply not available, and WebKit ignores roughly half of the NotificationOptions dictionary. This guide covers each of those, with runnable code, and sits inside the broader Core Protocols & Browser Implementation reference.

Prerequisites

Support matrix: what works on which Apple platform

Apple’s surface splits four ways, and the differences are not cosmetic. A macOS Safari tab can subscribe like any desktop browser. An iOS Safari tab cannot subscribe at all. An installed web app can, on both platforms. Treat the four columns as four separate deployment targets when you write your capability gate.

Capability macOS Safari tab macOS web app (Dock) iOS/iPadOS Safari tab iOS/iPadOS Home Screen web app
Minimum version Safari 16 / macOS Ventura Safari 17 / macOS Sonoma Safari 16.4 / iOS 16.4
Service worker registration Yes Yes Yes Yes
Notification.requestPermission() Yes, on a user gesture Yes, on a user gesture Not exposed Yes, on a user gesture
pushManager.subscribe() Yes Yes Not available Yes
VAPID (applicationServerKey) Required Required Required
userVisibleOnly: false (silent push) No No No
Notification actions Ignored Ignored Ignored
navigator.setAppBadge() No Yes No Yes
Declarative Web Push Safari 18.4+ Safari 18.4+ Safari 18.4+

Two rows carry most of the operational weight. The iOS Safari tab column is empty because Apple gates the Push and Notification interfaces behind installation — a subject with its own walkthrough in why iOS Web Push requires Add to Home Screen. And userVisibleOnly: false is unavailable everywhere on Apple platforms, which means every push you send must produce a notification the user can see.

How a push actually reaches an Apple device

Subscriptions created on Apple platforms return an endpoint on the host web.push.apple.com. That host is a standards-compliant RFC 8030 front door in front of Apple Push Notification service; your server never talks to APNs directly and never needs an APNs certificate or .p8 token. You POST the encrypted record with a VAPID Authorization header exactly as you would to FCM or Mozilla autopush, and Apple’s infrastructure handles the relay to the device.

Standards Web Push delivery path on Apple platforms An application server signs an ES256 VAPID JWT and posts an aes128gcm record to a web.push.apple.com endpoint. Apple's push service queues by TTL and urgency and relays over APNs to the device, where a Home Screen web app or macOS Safari shows the notification. RFC 8030 transport · RFC 8291 aes128gcm · RFC 8292 VAPID No Apple Developer account, no certificate, no push package Application server ES256 VAPID JWT aes128gcm record 4 KB ceiling HTTP/2 web.push.apple.com validates the JWT queues by TTL + Urgency relays over APNs APNs Apple device Home Screen web app or macOS Safari showNotification() Endpoint host: web.push.apple.com — the rest of the URL is an opaque token aud must be https://web.push.apple.com · exp no more than 24 hours out · sub must be a mailto: or https: URL On iPhone and iPad, subscribe() only exists once the site is installed to the Home Screen
Standards Web Push on Apple platforms: one signed, encrypted request to web.push.apple.com, relayed over APNs to the device.

Because the endpoint is opaque, your storage layer treats an Apple subscription exactly like a Chromium or Firefox one: three fields — endpoint, p256dh, auth — written atomically. The only reason to inspect the endpoint host is routing observability, or per-provider connection pooling in a multi-provider push gateway, never conditional payload construction.

The iOS prerequisite: manifest plus Add to Home Screen

On iPhone and iPad, a plain Safari tab cannot subscribe. The user must open the Share sheet, choose Add to Home Screen, and launch the resulting icon. Only inside that standalone context does WebKit expose the Notification and Push interfaces. There is no beforeinstallprompt on WebKit and no API that triggers the Share sheet, so installation is an interaction-design problem rather than an engineering one.

What you control is whether the installed result behaves like an app. Four manifest fields do the heavy lifting:

{
  "id": "/?source=pwa",
  "name": "Ledger — invoices and payments",
  "short_name": "Ledger",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4f46e5",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

display: "standalone" is what removes the Safari chrome and puts the app into the standalone display mode your feature detection will key on. scope bounds which URLs stay inside the app rather than kicking out to Safari — set it too narrowly and a navigation silently ejects the user, losing the push-capable context. start_url is the launch target; adding a query parameter makes installed sessions trivially attributable in analytics. Since iOS 16.4, WebKit reads icons from the manifest’s icons array, though keeping an apple-touch-icon link element in your HTML costs nothing and covers older installs.

Link the manifest from every page that could be the one the user installs from, not just the home page:

<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icons/icon-180.png">

Subscribing: the seven-step path

The subscription flow on Apple platforms is the standard flow plus two gates — installation and gesture. Follow these steps in order; skipping either gate produces a rejection that looks like a key problem but is not.

  1. Serve the manifest and register the service worker. Registration works in an ordinary iOS tab, so do it unconditionally on page load. It costs nothing and means the worker is already active the first time the installed app launches.
  2. Detect the context before you offer anything. Probe for the APIs themselves rather than parsing the user agent, and treat “not installed” as a distinct state from “not supported”.
  3. Render an opt-in control the user must click. Never call requestPermission() on load, on scroll, or from a setTimeout — Safari requires transient activation and rejects deferred calls.
  4. Call Notification.requestPermission() synchronously inside the handler. Do not await anything before it; an intervening await can consume the activation on some WebKit builds.
  5. Call registration.pushManager.subscribe() with userVisibleOnly: true. Apple requires it. Pass the VAPID public key as a Uint8Array.
  6. POST the serialised subscription to your server and store endpoint, p256dh and auth in one transaction alongside the consent timestamp.
  7. Verify with a real send to the returned web.push.apple.com endpoint from a device you can watch.
Subscription stages on Apple platforms Stages run left to right: serve a manifest, the user installs to the Home Screen, launch in standalone mode, register the service worker, then a user gesture, requestPermission, subscribe, and finally an endpoint issued on web.push.apple.com. 1 · Manifest display standalone icons and scope 2 · User installs Add to Home Screen manual, no API 3 · Standalone launched from icon APIs now exposed 4 · Worker ready serviceWorker.ready scope covers page Stages 1 to 3 apply to iPhone and iPad only — macOS Safari starts at stage 4 5 · User gesture tap the opt-in control 6 · Permission requestPermission() must return granted 7 · Subscribe userVisibleOnly true VAPID public key Endpoint issued web.push.apple.com Stages 5 and 6 must happen in the same activation — an awaited call before requestPermission can lose it.
The subscription path on Apple platforms: two extra gates — installation on iOS, and transient activation everywhere — wrapped around an otherwise standard subscribe.

Here is the whole client path as one function. Note that the gesture handler is not async before the permission call: the await comes after.

// Client: Apple-safe opt-in. Register early, subscribe only on a real click.
const VAPID_PUBLIC_KEY = document.querySelector('meta[name="vapid-key"]').content;

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}

export function detectPushContext() {
  const standalone =
    window.matchMedia('(display-mode: standalone)').matches ||
    window.navigator.standalone === true;
  const hasPush = 'serviceWorker' in navigator && 'PushManager' in window;
  const hasNotification = 'Notification' in window;
  return {
    canSubscribe: hasPush && hasNotification,
    standalone,
    // APIs missing but a worker is available: almost always an uninstalled iOS tab.
    needsInstall: 'serviceWorker' in navigator && !hasPush && !standalone,
  };
}

// Registration is safe everywhere, including an uninstalled iOS tab.
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js', { scope: '/' });
}

document.querySelector('#enable-push').addEventListener('click', (event) => {
  // Synchronous inside the gesture — no await before this line.
  const request = Notification.requestPermission();

  request
    .then(async (permission) => {
      if (permission !== 'granted') return;
      const registration = await navigator.serviceWorker.ready;
      const existing = await registration.pushManager.getSubscription();
      const subscription =
        existing ??
        (await registration.pushManager.subscribe({
          userVisibleOnly: true, // Apple requires this; false is rejected.
          applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
        }));
      await fetch('/api/push/subscriptions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(subscription.toJSON()),
      });
    })
    .catch((err) => console.warn('push opt-in failed', err.name, err.message));
});

getSubscription() before subscribe() matters more on Apple platforms than elsewhere, because an installed web app is frequently relaunched and a redundant subscribe() with a different key throws InvalidStateError. The general lifecycle rules live in service worker registration patterns.

VAPID against web.push.apple.com

Apple’s push service is the strictest VAPID validator of the three. The same JWT that FCM accepts can be rejected here, and the rejection is usually a 403 with a short reason string rather than anything descriptive. Three claims account for nearly all of it.

Claim Requirement at web.push.apple.com Common failure
aud Exactly the endpoint origin, https://web.push.apple.com — origin only, no path, no trailing slash Senders that pass the full endpoint URL as the audience
exp A Unix timestamp no more than 24 hours in the future, and not in the past Clock drift on a dispatch node; hard-coded long-lived tokens
sub A syntactically valid mailto: or https: URL that reaches a human A bare address like ops@example.com with no scheme

The sub rule catches the most teams. RFC 8292 describes sub as a contact URI, and Chromium will happily forward a malformed one; Apple validates the scheme and rejects. Use mailto:push-ops@yourdomain.com or an https: contact page URL, and make sure the mailbox is actually monitored — it is the address Apple uses if your traffic is causing problems.

// Server: one dispatch path for Apple, FCM and autopush.
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:push-ops@yourdomain.com', // Apple validates the URL scheme here.
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendToApple(subscription, payload) {
  const body = JSON.stringify(payload);
  if (Buffer.byteLength(body) > 3500) {
    throw new Error('Trim the payload — the aes128gcm record must stay under 4 KB');
  }
  try {
    // web-push performs ECDH + HKDF + aes128gcm encryption per RFC 8291.
    return await webpush.sendNotification(subscription, body, {
      TTL: 3600,
      urgency: 'high',
      topic: 'order-updates', // Collapses an older undelivered message of the same topic.
    });
  } catch (err) {
    if (err.statusCode === 410 || err.statusCode === 404) {
      await deleteSubscription(subscription.endpoint);
      return null;
    }
    throw err;
  }
}

Never hardcode either half of the key pair: read process.env.VAPID_PUBLIC_KEY and process.env.VAPID_PRIVATE_KEY from a secrets manager. Rotation has the same subscription-invalidation consequences on Apple as anywhere else, covered in VAPID key generation & rotation, and the certificate-versus-JWT distinction that trips up teams coming from native iOS is unpacked in VAPID vs APNs authentication differences.

The visible-notification contract

Apple does not implement silent push. pushManager.subscribe({ userVisibleOnly: false }) is rejected, and there is no budget mechanism that lets you earn an exception the way Chromium nominally does. Every push that reaches an Apple device must terminate in a showNotification() call inside the push handler, before the promise passed to event.waitUntil() settles.

If your handler resolves without showing anything, WebKit shows a generic system-supplied notification on your behalf, and repeated offences can cost the origin its push permission entirely. This has a concrete design consequence: you cannot use Web Push on Apple platforms as a background data-sync trigger. Anything that looks like “wake the worker, refresh a cache, show nothing” needs a different mechanism, or needs to be paired with a notification the user actually benefits from.

// Service worker: never resolve the push event without a visible notification.
self.addEventListener('push', (event) => {
  event.waitUntil(
    (async () => {
      let data;
      try {
        data = event.data ? event.data.json() : {};
      } catch {
        data = {};
      }
      await self.registration.showNotification(data.title || 'New activity', {
        body: data.body || 'Open the app for details.',
        tag: data.tag || 'default',      // Honoured: replaces an earlier notification.
        data: { url: data.url || '/' },  // Honoured: read back in notificationclick.
        lang: data.lang || 'en',
        dir: 'auto',
      });
      if ('setAppBadge' in self.navigator && typeof data.unread === 'number') {
        await self.navigator.setAppBadge(data.unread);
      }
    })()
  );
});

What Safari does with showNotification() options

The NotificationOptions dictionary is where cross-engine assumptions break. WebKit renders a deliberately minimal card: your app icon, a title, a body, and system-managed timing. Options it does not implement are ignored silently — no throw, no console warning — so a notification that looks rich in Chrome arrives stripped on iPhone.

What Safari renders from showNotification options A mock Safari notification card showing app icon, title and body, next to a green panel listing honoured options such as title, body, tag, data, lang and dir, and a red panel listing ignored options including actions, image, badge, vibrate, silent and requireInteraction. Safari's notification surface APP LEDGER · now Invoice 4021 was paid Tap to open the payment record. Artwork comes from the manifest icons System owns sound, grouping, dismissal App icon badge is separate navigator.setAppBadge(count) installed web apps only not the badge option Honoured by WebKit title and body — the whole visible payload tag — replaces an earlier notification data — read back in notificationclick lang and dir — right-to-left copy notificationclick with clients.openWindow Ignored or unsupported actions — no buttons render at all image, icon, badge, vibrate, silent requireInteraction — system owns dwell time renotify and timestamp Ignored options fail silently — no throw, no console warning, just a plainer card.
WebKit renders a deliberately minimal notification: title, body, manifest icon. Everything else in NotificationOptions is either honoured invisibly or dropped without warning.

The practical rule is to design the Apple card first and treat richer engines as the enhancement, not the other way round. Put the entire message into title and body, use tag for coalescing, and carry the destination in data so notificationclick can route. If your product depends on buttons, read adding action buttons to web push notifications for the degradation pattern, and notification icon and badge rendering differences for how artwork resolves per engine.

App Badging with navigator.setAppBadge()

The number on an app icon is a separate API from notifications. navigator.setAppBadge(count) and navigator.clearAppBadge() both return promises and are available in installed web apps on iOS and iPadOS 16.4+, and in macOS web apps added to the Dock. They are not available in a macOS Safari tab, and they are unrelated to the badge option in NotificationOptions, which WebKit ignores.

The badge is exposed on both Navigator and WorkerNavigator, so you can set it from a page or from inside the push handler. Setting it from the worker is what keeps the count accurate when the app is closed:

// Either context: guard, then set. Passing 0 or omitting the count clears the dot.
async function syncBadge(unread) {
  if (!('setAppBadge' in navigator)) return;
  try {
    if (unread > 0) {
      await navigator.setAppBadge(unread);
    } else {
      await navigator.clearAppBadge();
    }
  } catch {
    // Non-fatal: unsupported platforms and uninstalled contexts reject.
  }
}

// Clear the badge when the user actually reads the queue.
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') syncBadge(0);
});

Send the authoritative unread count in the push payload rather than incrementing a locally stored number. Pushes are best-effort and can be dropped or coalesced by topic, so a client-side counter drifts within days; an absolute count is self-healing on the next delivery.

Legacy Safari Push Notifications, and why it is not this

Apple has shipped two entirely separate push mechanisms for the web. The older one, introduced with OS X Mavericks in 2013, is the certificate-based Safari Push Notifications API: you registered a Website Push ID such as web.com.example in an Apple Developer account, built a signed .pushPackage zip containing website.json, an icon set and a CMS signature, hosted a small web service Apple would poll, and called safari.pushNotification.requestPermission() from the page. It was macOS-only and never came to iOS.

Legacy Safari Push Notifications compared with standards Web Push Five rows compare the two mechanisms: authentication by Apple Developer certificate versus VAPID JWT, safari.pushNotification versus pushManager.subscribe, a signed push package versus an aes128gcm record, macOS only versus macOS iOS and iPadOS, and deprecated versus supported. Legacy Safari Push introduced 2013, macOS only Standards Web Push Safari 16 and later Authentication Apple Developer certificate Website Push ID web.com.example VAPID ES256 JWT no Apple account required Client API safari.pushNotification callback-based permission pushManager.subscribe() service worker push event Payload signed push package zip plus a hosted web service aes128gcm record under 4 KB RFC 8291 end to end Platforms macOS Safari only never reached iPhone or iPad macOS, iOS and iPadOS installed web apps on iOS Status deprecated by Apple do not start new work here the supported path portable across every engine Both can coexist on a Mac, but only the standards path reaches iPhone and iPad.
The certificate-based Safari Push Notifications API against standards Web Push, judged on the five axes that determine which one you should build against.

Apple has deprecated the legacy mechanism in favour of standards Web Push and advises migrating. Practically, the migration is not an upgrade path — it is a rewrite of the client and a re-permission of every user, because a Website Push ID permission and a Web Push subscription are unrelated grants. Users who allowed the legacy prompt will not be silently carried over; they must accept the standards prompt as new subscribers.

If you inherit a codebase that references safari.pushNotification, websitePushID, or a .pushPackage build step, none of it applies to the flow on this page. Delete the branch rather than maintaining both, unless you have measurable macOS traffic on very old Safari versions — and check the browser compatibility reference before assuming you do.

Verifying on a real device

The iOS Simulator does not receive pushes, so verification requires hardware. Connect the device to a Mac by cable, enable Settings → Safari → Advanced → Web Inspector on the device and the Develop menu in desktop Safari, then attach the inspector to the installed web app — it appears under the device name, listed by app name rather than under Safari.

Then work through this sequence:

# 1. Confirm the subscription your client stored really points at Apple.
psql -c "select endpoint from push_subscriptions order by created_at desc limit 1;"
# Expect: https://web.push.apple.com/...

# 2. Send one message and read the status line.
node -e "require('./send.js').sendToApple(sub, { title: 'Ping', body: 'Test' })" -v

# 3. A successful send returns 201 Created. Anything else is in the table below.

In the Web Inspector console, Notification.permission should read granted, and await navigator.serviceWorker.ready.then(r => r.pushManager.getSubscription()) should return a subscription whose endpoint matches what your database holds. If the console reports Notification as undefined, you are inspecting a Safari tab rather than the installed app.

Error and edge-case matrix

Condition Cause Fix
TypeError: Notification is not defined on iOS Running in a Safari tab, not an installed web app Detect standalone mode and show an install hint instead of an opt-in
NotAllowedError from requestPermission() Called outside a user gesture, or the activation was consumed by an earlier await Call it synchronously in the click handler; see pushManager.subscribe fails with NotAllowedError
subscribe() rejects with NotSupportedError userVisibleOnly: false was passed Apple only permits true; redesign anything that needed silent push
403 with a JWT reason from Apple Bad aud, a sub without a URL scheme, or an expired exp Set aud to the endpoint origin only and sub to a mailto: or https: URL
410 Gone shortly after install User deleted the Home Screen icon or turned notifications off in Settings Delete the row; a deleted web app takes its subscription with it
413 Payload Too Large Encrypted record exceeded 4 KB Send identifiers, fetch detail client-side after notificationclick
429 Too Many Requests Per-origin rate limit at Apple’s push service Honour Retry-After and back off exponentially
Notification arrives with generic system text The push handler resolved without calling showNotification() Always show something; repeated offences can revoke permission
Buttons missing that work in Chrome actions is ignored by WebKit Route everything through the default notificationclick
Badge never appears Called in a Safari tab, or badge option used instead of the API Use navigator.setAppBadge() from an installed context
Subscription vanishes after an iOS update Endpoint rotation without a pushsubscriptionchange handler Re-check getSubscription() on every launch and re-register if null

Back to Core Protocols & Browser Implementation

FAQ

Do I need an Apple Developer account to send Web Push to Safari?

No. Standards Web Push on Safari 16 and later uses VAPID, the same ECDSA P-256 JWT authentication as Chrome and Firefox. You need no Apple Developer account, no certificate and no push package. Those artefacts belong to the older, deprecated Safari Push Notifications API.

Why is Notification undefined in Safari on my iPhone?

Because the page is running in a normal Safari tab. On iOS and iPadOS, WebKit exposes the Notification and Push interfaces only inside a web app that the user has added to the Home Screen and launched from its icon. Detect standalone display mode and show an install hint rather than an opt-in button.

Can I send a silent push to Safari to refresh data in the background?

No. Apple rejects userVisibleOnly: false, and every push must end in a showNotification() call before the push event’s waitUntil promise settles. If your handler shows nothing, the system substitutes a generic notification and repeated offences can cost the origin its permission.

Which showNotification options does Safari ignore?

WebKit ignores actions, image, icon, badge, vibrate, silent, requireInteraction, renotify and timestamp. It honours title, body, tag, data, lang and dir, and it fires notificationclick normally. Ignored options fail silently — there is no throw and no console warning.

Why does Apple reject a VAPID JWT that FCM accepts?

Apple validates the claims more strictly. The aud claim must be exactly the endpoint origin https://web.push.apple.com with no path, exp must be no more than 24 hours in the future and not in the past, and sub must be a syntactically valid mailto: or https: URL. A bare email address without the mailto: scheme is the most common rejection.