Push-уведомления: как работают APNs, FCM и Web Push

Push-уведомления: как работают APNs, FCM и Web Push Полезное

Push-уведомление — это сообщение, которое сервер приложения отправляет на устройство через push-сервис платформы (Apple, Google или сервис браузера), а операционная система или браузер показывает его, даже если приложение или вкладка сайта закрыты (для Web Push сам браузер должен работать хотя бы в фоне). Ниже — схема доставки, сравнение APNs, FCM и Web Push, разрешения (включая Android 13+) и пример Web Push на Node.js.

Серверный код проверен на Node.js 24.21 и 22.23 23 сентября 2026 года.

Четыре термина, которые путают

  • Push-сообщение — данные, которые доставил push-сервис. Может прийти без показа на экране (сигнал «синхронизируй чат»).
  • Уведомление — то, что видит пользователь: баннер, запись в шторке, звук. Его рисует ОС или браузер.
  • Push-сервис — посредник платформы, который держит соединение с устройством: APNs у Apple, Firebase Cloud Messaging (FCM) у Android с сервисами Google, свой сервис у каждого браузера (у Safari — APNs).
  • Адрес устройства — токен (APNs, FCM) или подписка с URL endpoint (Web Push): кому доставить сообщение.

Push — способ доставки, уведомление — способ показа. Локальное уведомление (напоминание, запланированное самим приложением) обходится без push-сервиса.

Как проходит push-уведомление: схема по шагам

  1. Приложение или страница запрашивает разрешение на уведомления.
  2. Устройство регистрируется в push-сервисе и получает токен или подписку.
  3. Клиент отправляет токен на сервер приложения, тот сохраняет его за пользователем.
  4. При событии (оплата прошла, пришло сообщение) сервер отправляет в push-сервис адрес устройства, данные и параметры доставки.
  5. Push-сервис доставляет сообщение по уже открытому соединению или держит в очереди, пока устройство не выйдет в сеть, но не дольше срока жизни (TTL).
  6. ОС или браузер показывает уведомление либо будит приложение или service worker, чтобы тот решил, что показать.

Заряд экономится на шаге 5: устройство держит одно общее соединение с сервисом платформы вместо опроса каждого сервера.

APNs, FCM и Web Push: сравнение

APNs (Apple) FCM (Google) Web Push (браузеры)
Где работает iOS, iPadOS, macOS, watchOS, tvOS, visionOS Android с сервисами Google; на iOS и в вебе SDK Firebase доставляет через APNs и Web Push Chrome, Edge, Firefox, Safari
Адрес устройства device token registration token подписка: endpoint + ключи p256dh и auth
Как сервер доказывает право отправки JWT, подписанный ключом .p8, или TLS-сертификат OAuth 2.0 токен сервисного аккаунта (HTTP v1 API) JWT по стандарту VAPID (RFC 8292)
Протокол HTTP/2 HTTPS, JSON HTTP (RFC 8030), тело шифруется по RFC 8291
Лимит полезной нагрузки 4096 байт (VoIP — 5120) 4096 байт (из консоли Firebase — 1000 символов) сервис обязан принять тело до 4096 байт в зашифрованном виде; открытого текста помещается около 3,9 КБ

Поэтому в push кладут короткий текст и идентификатор, а подробности приложение загружает само.

У FCM старые HTTP и XMPP API с серверным ключом (Authorization: key=...) объявлены устаревшими 20 июня 2023 года, их отключение началось 22 июля 2024 года. Код из старых туториалов больше не работает, нужен HTTP v1 с OAuth-токеном.

На Android без сервисов Google (часть устройств Huawei, магазины вне Google Play) FCM не работает, используют push-сервис производителя или магазина. Архитектура та же: токен, сервер, посредник.

Разрешение пользователя

На Android и iOS без разрешения сообщение с данными до приложения дойдет, но уведомление не покажется. В браузере без разрешения подписку не создать, и адреса доставки просто нет.

Платформа Как запрашивают Важная граница
Android 13+ (API 33) runtime-разрешение POST_NOTIFICATIONS до Android 13 такого разрешения не было, уведомления включены по умолчанию; при targetSdk 32 и ниже система сама спросит после создания первого канала; с Android 8 каналы отключаются по отдельности
iOS, iPadOS UNUserNotificationCenter.requestAuthorization системный диалог показывается один раз, повторно включить можно только в настройках
Браузер Notification.requestPermission() ряд браузеров показывает запрос только в ответ на действие пользователя (клик)
Safari на iPhone и iPad как в браузере, только по нажатию Web Push с iOS и iPadOS 16.4 и только для веб-приложения на экране «Домой» (в манифесте display: standalone или fullscreen)

Минимальный запрос разрешения в Android-приложении на Kotlin (AndroidX Activity). В AndroidManifest.xml объявляется <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />, а в коде активности:

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat

class MainActivity : ComponentActivity() {
    private val requestPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (!granted) {
                // приложение продолжает работать без уведомлений
            }
        }

    private fun askNotificationsIfNeeded() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED
        ) {
            requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }
}

Вызывать askNotificationsIfNeeded() лучше не при первом запуске, а когда польза понятна, например после оформления заказа. Отказ — нормальный сценарий.

Web Push на практике: от подписки до отправки

Шаг 1. Ключи VAPID и заголовок авторизации на сервере

Минимальный пример на Node.js без сторонних пакетов: пара ключей VAPID (кривая P-256) и JWT для заголовка Authorization запроса к push-сервису.

import { generateKeyPairSync, sign, verify } from 'node:crypto';

// 1. Пара ключей VAPID: эллиптическая кривая P-256
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const jwkPub = publicKey.export({ format: 'jwk' });
const b64u = (buf) => Buffer.from(buf).toString('base64url');

// Открытый ключ в «сыром» виде: 0x04 || X || Y = 65 байт
const rawPub = Buffer.concat([Buffer.from([4]),
  Buffer.from(jwkPub.x, 'base64url'), Buffer.from(jwkPub.y, 'base64url')]);
console.log('public key bytes:', rawPub.length);
console.log('private key bytes:', Buffer.from(privateKey.export({ format: 'jwk' }).d, 'base64url').length);

// 2. JWT для заголовка Authorization (ES256)
const endpoint = 'https://push.example.com/send/abc123';
const header = { typ: 'JWT', alg: 'ES256' };
const claims = {
  aud: new URL(endpoint).origin,                   // origin push-сервиса
  exp: Math.floor(Date.now() / 1000) + 12 * 3600,  // не дольше 24 часов
  sub: 'mailto:admin@example.com',                 // контакт отправителя
};
const unsigned = b64u(JSON.stringify(header)) + '.' + b64u(JSON.stringify(claims));
const sig = sign('sha256', Buffer.from(unsigned), { key: privateKey, dsaEncoding: 'ieee-p1363' });
const jwt = unsigned + '.' + b64u(sig);

console.log('signature bytes:', sig.length);
console.log('aud:', claims.aud);
console.log('verify:', verify('sha256', Buffer.from(unsigned),
  { key: publicKey, dsaEncoding: 'ieee-p1363' }, sig));
console.log('Authorization: vapid t=' + jwt.slice(0, 20) + '..., k=' + b64u(rawPub).slice(0, 12) + '...');

Сохраните как vapid.mjs и запустите node vapid.mjs. Вывод (ключи случайные, хвост последней строки у вас будет другим):

public key bytes: 65
private key bytes: 32
signature bytes: 64
aud: https://push.example.com
verify: true
Authorization: vapid t=eyJ0eXAiOiJKV1QiLCJh..., k=BBV32VDHtodV...

Что здесь важно:

  • открытый ключ (65 байт в base64url) отдается браузеру при подписке, закрытый (32 байта) хранится только на сервере;
  • aud — origin push-сервиса из endpoint подписки, а не адрес вашего сайта; exp — не дальше 24 часов вперед (RFC 8292);
  • JWT подтверждает только, кто отправитель. Тело сообщения шифруется ключами подписки по RFC 8291 — эту часть берут из готовой библиотеки (например, web-push для Node.js), а не пишут вручную.

Типичная ошибка: подпись в неверном формате

Функция sign в Node.js по умолчанию выдает подпись ECDSA в формате DER, а JWT (ES256) требует «сырой» формат r||s ровно из 64 байт.

import { generateKeyPairSync, sign, verify } from 'node:crypto';
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const data = Buffer.from('header.claims');
// Ошибка: подпись по умолчанию - в формате DER
const der = sign('sha256', data, privateKey);
console.log('DER length:', der.length);
console.log('verify as JWS:', verify('sha256', data, { key: publicKey, dsaEncoding: 'ieee-p1363' }, der));
// Исправление: формат r||s, ровно 64 байта
const jws = sign('sha256', data, { key: privateKey, dsaEncoding: 'ieee-p1363' });
console.log('JWS length:', jws.length);
console.log('verify as JWS:', verify('sha256', data, { key: publicKey, dsaEncoding: 'ieee-p1363' }, jws));
DER length: 71
verify as JWS: false
JWS length: 64
verify as JWS: true

Длина DER-подписи плавает (обычно 70-72 байта), проверка по правилам JWS не проходит, и push-сервис отвечает ошибкой авторизации (401 или 403), хотя ключи верные.

Шаг 2. Подписка в браузере

Запрос разрешения по клику, регистрация service worker, подписка и отправка ее на сервер:

const VAPID_PUBLIC_KEY = 'BBV32VDHtodV...'; // открытый ключ сервера, base64url

function base64UrlToUint8Array(s) {
  const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}

document.querySelector('#subscribe').addEventListener('click', async () => {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
  const permission = await Notification.requestPermission(); // только по клику
  if (permission !== 'granted') return; // 'denied' или 'default'
  await navigator.serviceWorker.register('/sw.js');
  const reg = await navigator.serviceWorker.ready;
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: base64UrlToUint8Array(VAPID_PUBLIC_KEY),
  });
  await fetch('/api/push/subscriptions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(sub), // endpoint + keys.p256dh + keys.auth
  });
});

userVisibleOnly: true — обещание, что каждое push-сообщение закончится видимым уведомлением. Chrome и Safari без этого флага подписку не создают, поэтому на скрытые фоновые push в вебе рассчитывать не стоит. Web Push работает только на HTTPS (для разработки — на localhost).

Шаг 3. Service worker показывает уведомление

Файл sw.js просыпается при приходе push-сообщения, даже если вкладка закрыта:

self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title || 'Новое событие', {
      body: data.body || '',
      data: { url: data.url || '/' },
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const target = new URL(event.notification.data.url, self.location.origin);
  if (target.origin !== self.location.origin) return; // только свой сайт
  event.waitUntil(clients.openWindow(target.href));
});

Проверка origin не дает превратить уведомление в переход на чужой сайт.

Шаг 4. Ответы push-сервиса

Сервер отправляет POST на endpoint подписки с заголовками Authorization: vapid t=..., k=..., Content-Encoding: aes128gcm, TTL (сколько секунд хранить сообщение) и при необходимости Urgency. Ответы по RFC 8030:

Ответ Значение Действие сервера
201 Created сообщение принято в очередь ничего, это не гарантия показа
404 или 410 подписка больше не действует удалить подписку из базы
413 тело слишком большое сократить данные
429 слишком частые запросы повторить позже с паузой

Доставка не гарантирована

Ответ 201 от push-сервиса или 200 от FCM означает «принято», а не «прочитано». Сообщение может не показаться: устройство долго офлайн и истек TTL, режим Doze в Android откладывает сообщения обычного приоритета, пользователь выключил канал. Поэтому критичные данные (статус оплаты, новое сообщение) приложение подтягивает с сервера при открытии, а push служит сигналом.

Безопасность и приватность

  • Закрытые ключи (VAPID, .p8 для APNs, JSON сервисного аккаунта FCM) — только на сервере, в менеджере секретов, не в репозитории и не в клиентском коде.
  • Токен и endpoint — адрес доставки: храните их как чувствительные данные, привязывайте к пользователю и удаляйте при выходе из аккаунта.
  • Текст уведомления виден на заблокированном экране. Коды подтверждения и персональные данные туда не кладут: безопаснее «есть новое сообщение», а содержимое — после входа.
  • В APNs и FCM данные проходят через инфраструктуру Apple и Google. Тело Web Push зашифровано ключами подписки, push-сервис его не читает, но видит метаданные (время, размер, получателя).

Если уведомления не приходят

Симптом Частая причина Что проверить
На Android 13+ ничего не видно нет POST_NOTIFICATIONS или канал отключен настройки приложения, код запроса разрешения
В браузере запрос не появляется вызов не из клика или раньше выбрано «Блокировать» обработчик события, настройки сайта
subscribe падает с ошибкой нет HTTPS, service worker не активен, неверный ключ serviceWorker.ready, длина ключа 65 байт
Сервис отвечает 401/403 ошибка VAPID aud, exp, формат подписи 64 байта
На iPhone Web Push не работает сайт открыт во вкладке Safari iOS 16.4+, сайт на экране «Домой», манифест с display: standalone

Выводы

  • Путь push-уведомления: «сервер приложения -> push-сервис платформы -> ОС или браузер», напрямую сервер до устройства не достучится.
  • APNs, FCM и Web Push устроены одинаково (адрес устройства + авторизация отправителя + нагрузка до 4 КБ), но отличаются ключами и протоколом; у FCM остался только HTTP v1 API.
  • Для показа нужно разрешение: в Android 13+ это runtime-разрешение POST_NOTIFICATIONS, в браузере — запрос по клику, на iPhone Web Push доступен только веб-приложениям на экране «Домой».
  • «Принято» не равно «показано»: критичные данные приложение загружает само, отозванные подписки (404/410) сервер удаляет. Ключи — только на сервере.

Где применяется / связь с практикой

Освойте тему на практике

Статусы заказов, сообщения в чатах, напоминания о занятиях — для Android-разработчика push стандартная задача: подключить FCM, запросить разрешение, настроить каналы и обработать нажатие. Если хотите системно освоить создание Android-приложений на Kotlin, посмотрите курс Android Developer. Basic. Разобрать отдельные темы мобильной и веб-разработки с преподавателями можно на бесплатных открытых уроках Otus.

FAQ

Чем push отличается от SMS?
SMS доставляет оператор по номеру телефона, приложение не нужно. Push идет через push-сервис в конкретное приложение или браузер по токену и показывается только с разрешения.

Можно ли отправить push всем пользователям сайта без подписки?
Нет. Без подписки, созданной с разрешения пользователя, у сервера нет адреса доставки.

Нужен ли свой сервер для push-уведомлений?
Нужен тот, кто хранит токены и отправляет запросы в push-сервис: свой бэкенд или облачный сервис рассылок. Напоминание самому себе устройство показывает локальным уведомлением, без push.

OTUS Журнал