Push-уведомление — это сообщение, которое сервер приложения отправляет на устройство через push-сервис платформы (Apple, Google или сервис браузера), а операционная система или браузер показывает его, даже если приложение или вкладка сайта закрыты (для Web Push сам браузер должен работать хотя бы в фоне). Ниже — схема доставки, сравнение APNs, FCM и Web Push, разрешения (включая Android 13+) и пример Web Push на Node.js.
Содержание
- Четыре термина, которые путают
- Как проходит push-уведомление: схема по шагам
- APNs, FCM и Web Push: сравнение
- Разрешение пользователя
- Web Push на практике: от подписки до отправки
- Доставка не гарантирована
- Безопасность и приватность
- Если уведомления не приходят
- Выводы
- Где применяется / связь с практикой
- FAQ
Серверный код проверен на 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-уведомление: схема по шагам
- Приложение или страница запрашивает разрешение на уведомления.
- Устройство регистрируется в push-сервисе и получает токен или подписку.
- Клиент отправляет токен на сервер приложения, тот сохраняет его за пользователем.
- При событии (оплата прошла, пришло сообщение) сервер отправляет в push-сервис адрес устройства, данные и параметры доставки.
- Push-сервис доставляет сообщение по уже открытому соединению или держит в очереди, пока устройство не выйдет в сеть, но не дольше срока жизни (TTL).
- ОС или браузер показывает уведомление либо будит приложение или 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.



