Настройка веб push-уведомлений: полное руководство 2026

Web push-уведомления в 2026 году стали обязательным инструментом для удержания пользователей, и я покажу, как их правильно настроить. За 10 лет работы с веб-проектами я внедрил push-уведомления для сотен сайтов — от простых блогов до крупных интернет-магазинов.

Что такое web push-уведомления и зачем они нужны

Web push-уведомления — это сообщения, которые приходят пользователю прямо в браузер или на рабочий стол, даже когда сайт закрыт. Честно говоря, это один из самых эффективных способов вернуть пользователя на сайт.

На практике я видел, как правильно настроенные push-уведомления увеличивают возвратность пользователей на 30-50%. У одного моего клиента — интернет-магазина электроники — CTR по push достигал 8%, что в разы выше, чем у email-рассылок.

Основные преимущества web push:

А вот минусы, с которыми приходится мириться:

Как работают web push-уведомления технически

Чтобы правильно настроить push, нужно понимать механизм их работы. Я всегда объясняю клиентам эту схему простыми словами:

1. Регистрация Service Worker — специальный скрипт, который работает в фоне
2. Запрос разрешения — браузер спрашивает пользователя о согласии
3. Подписка — браузер создаёт уникальный endpoint для уведомлений
4. Отправка — сервер отправляет уведомление через push-сервис браузера
5. Доставка — браузер показывает уведомление пользователю

Грубо говоря, каждый браузер имеет свой push-сервис:

ℹ️
Важно знать: Service Worker должен быть размещён в корне сайта или в той же директории, где планируете отправлять push. Это ограничение безопасности браузеров.

Весь процесс построен на стандартах Web Push Protocol и VAPID (Voluntary Application Server Identification). Эти технологии обеспечивают безопасность и единообразие работы во всех браузерах.

Настройка Service Worker для push-уведомлений

Service Worker — это сердце всей системы push-уведомлений. Я всегда создаю файл `sw.js` в корне сайта и регистрирую его на всех страницах.

Вот базовый Service Worker для push-уведомлений:

// sw.js
self.addEventListener('push', function(event) {
    const options = {
        body: 'У нас есть новости для вас!',
        icon: '/images/icon-192x192.png',
        badge: '/images/badge-72x72.png',
        tag: 'notification-tag',
        renotify: true,
        requireInteraction: false,
        actions: [
            {
                action: 'view',
                title: 'Посмотреть',
                icon: '/images/view-icon.png'
            },
            {
                action: 'close',
                title: 'Закрыть',
                icon: '/images/close-icon.png'
            }
        ],
        data: {
            url: 'https://example.com/news'
        }
    };

    if (event.data) {
        const payload = event.data.json();
        options.body = payload.body;
        options.icon = payload.icon;
        options.data.url = payload.url;
    }

    event.waitUntil(
        self.registration.showNotification('WebFull Blog', options)
    );
});

self.addEventListener('notificationclick', function(event) {
    event.notification.close();

    if (event.action === 'view' || !event.action) {
        event.waitUntil(
            clients.openWindow(event.notification.data.url)
        );
    }
});

self.addEventListener('notificationclose', function(event) {
    console.log('Уведомление закрыто пользователем');
    // Здесь можно отправить аналитику
});

Теперь нужно зарегистрировать Service Worker на сайте. Я добавляю этот код в `` всех страниц:

if ('serviceWorker' in navigator && 'PushManager' in window) {
    navigator.serviceWorker.register('/sw.js')
        .then(function(registration) {
            console.log('Service Worker зарегистрирован:', registration);
            
            // Проверяем, есть ли уже подписка
            return registration.pushManager.getSubscription();
        })
        .then(function(subscription) {
            if (!subscription) {
                // Подписки нет, можно показать кнопку подписки
                showSubscribeButton();
            } else {
                // Подписка есть, отправляем данные на сервер
                sendSubscriptionToServer(subscription);
            }
        })
        .catch(function(error) {
            console.error('Ошибка регистрации Service Worker:', error);
        });
}

function showSubscribeButton() {
    const subscribeBtn = document.getElementById('subscribe-btn');
    if (subscribeBtn) {
        subscribeBtn.style.display = 'block';
        subscribeBtn.onclick = subscribeUser;
    }
}

На практике у меня был случай, когда Service Worker не регистрировался из-за HTTPS. Помните: push-уведомления работают только по HTTPS (кроме localhost). Если у вас ещё нет SSL-сертификата, обязательно настройте его — читайте мою статью про SSL-сертификаты.

Генерация VAPID-ключей

VAPID (Voluntary Application Server Identification) — это стандарт аутентификации для push-сервисов. Без VAPID-ключей современные браузеры просто не будут доставлять уведомления.

Я генерирую VAPID-ключи через npm-пакет web-push:

npm install web-push -g
web-push generate-vapid-keys

Команда выдаст два ключа:

Честно говоря, я всегда сохраняю эти ключи в переменных окружения. Вот пример для PHP:

# .env
VAPID_PUBLIC_KEY="BMxYzvGfvBkHDkzJqpNgr1PfPkyR_LgFEIp2nS9-XRq9iWNxVG1cZLAJNxTwHnxC_HgCb3QWeLV4K9__Cj2rLhE"
VAPID_PRIVATE_KEY="lGAyujOXaWvKRTfGW4WK3bwQ9L6v2YRj8K5hRyUpHhY"
VAPID_SUBJECT="mailto:admin@webfull.ru"
⚠️
Безопасность: Никогда не показывайте приватный VAPID-ключ в браузере! Он должен храниться только на сервере. Если ключ утечёт, злоумышленники смогут отправлять уведомления от вашего имени.

На клиенте используем только публичный ключ для подписки:

const publicVapidKey = 'BMxYzvGfvBkHDkzJqpNgr1PfPkyR_LgFEIp2nS9-XRq9iWNxVG1cZLAJNxTwHnxC_HgCb3QWeLV4K9__Cj2rLhE';

function subscribeUser() {
    navigator.serviceWorker.ready
        .then(function(registration) {
            return registration.pushManager.subscribe({
                userVisibleOnly: true,
                applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
            });
        })
        .then(function(subscription) {
            console.log('Пользователь подписан:', subscription);
            sendSubscriptionToServer(subscription);
        })
        .catch(function(error) {
            console.error('Ошибка подписки:', error);
        });
}

// Функция для конвертации base64 в Uint8Array
function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
        .replace(/\-/g, '+')
        .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
}

Создание системы подписки пользователей

Правильная система подписки — это не просто кнопка "Разрешить уведомления". Нужно продумать UX, момент показа запроса и сохранение подписок на сервере.

Я всегда использую двухэтапную подписку:

  1. Сначала показываю свой красивый промпт с объяснением пользы
  2. Только после согласия запрашиваю разрешение браузера

Вот HTML для кастомного промпта:

<div id="push-prompt" class="push-prompt" style="display: none;">
    <div class="push-prompt__content">
        <h3>Получайте уведомления о новых статьях</h3>
        <p>Мы будем присылать только важные обновления, без спама. Отписаться можно в любой момент.</p>
        <div class="push-prompt__buttons">
            <button id="push-allow" class="btn btn--primary">Разрешить</button>
            <button id="push-deny" class="btn btn--secondary">Не сейчас</button>
        </div>
    </div>
</div>

JavaScript для управления подпиской:

class PushNotifications {
    constructor() {
        this.publicKey = 'ВАШ_ПУБЛИЧНЫЙ_VAPID_КЛЮЧ';
        this.apiEndpoint = '/api/push-subscription';
        this.init();
    }

    init() {
        if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
            console.log('Push уведомления не поддерживаются');
            return;
        }

        navigator.serviceWorker.register('/sw.js')
            .then(registration => {
                console.log('SW зарегистрирован');
                this.swRegistration = registration;
                this.checkSubscription();
            })
            .catch(error => {
                console.error('Ошибка SW:', error);
            });

        this.bindEvents();
    }

    bindEvents() {
        document.getElementById('push-allow')?.addEventListener('click', () => {
            this.subscribe();
        });

        document.getElementById('push-deny')?.addEventListener('click', () => {
            this.hidePrompt();
            // Запомним отказ в localStorage
            localStorage.setItem('push-denied', Date.now());
        });
    }

    checkSubscription() {
        this.swRegistration.pushManager.getSubscription()
            .then(subscription => {
                if (subscription) {
                    this.sendSubscriptionToServer(subscription);
                } else {
                    this.maybeShowPrompt();
                }
            });
    }

    maybeShowPrompt() {
        // Не показываем, если пользователь уже отказался недавно
        const denied = localStorage.getItem('push-denied');
        if (denied && (Date.now() - denied) < 7 * 24 * 60 * 60 * 1000) {
            return;
        }

        // Показываем через 30 секунд после загрузки страницы
        setTimeout(() => {
            this.showPrompt();
        }, 30000);
    }

    showPrompt() {
        const prompt = document.getElementById('push-prompt');
        if (prompt) {
            prompt.style.display = 'block';
        }
    }

    hidePrompt() {
        const prompt = document.getElementById('push-prompt');
        if (prompt) {
            prompt.style.display = 'none';
        }
    }

    subscribe() {
        this.hidePrompt();

        this.swRegistration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: this.urlBase64ToUint8Array(this.publicKey)
        })
        .then(subscription => {
            console.log('Подписка создана:', subscription);
            this.sendSubscriptionToServer(subscription);
        })
        .catch(error => {
            console.error('Ошибка подписки:', error);
            if (error.name === 'NotAllowedError') {
                // Пользователь отклонил запрос
                localStorage.setItem('push-denied', Date.now());
            }
        });
    }

    sendSubscriptionToServer(subscription) {
        fetch(this.apiEndpoint, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                subscription: subscription,
                user_agent: navigator.userAgent,
                url: window.location.href
            })
        })
        .then(response => response.json())
        .then(data => {
            console.log('Подписка сохранена на сервере:', data);
        })
        .catch(error => {
            console.error('Ошибка сохранения подписки:', error);
        });
    }

    urlBase64ToUint8Array(base64String) {
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
            .replace(/\-/g, '+')
            .replace(/_/g, '/');

        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);

        for (let i = 0; i < rawData.length; ++i) {
            outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
    }
}

// Инициализация
new PushNotifications();

На практике я заметил, что конверсия в подписку сильно зависит от момента показа промпта. Не стоит показывать его сразу — лучше дать пользователю освоиться на сайте.

Серверская часть на PHP

Для отправки push-уведомлений я использую библиотеку web-push-php. Она отлично работает с любыми PHP-проектами — от чистого PHP до Laravel и Bitrix.

Установка через Composer:

composer require minishlink/web-push

Создаю класс для работы с push-уведомлениями:

<?php

class WebPushManager
{
    private $webPush;
    private $db;

    public function __construct($vapidPublicKey, $vapidPrivateKey, $vapidSubject, $db)
    {
        $auth = [
            'VAPID' => [
                'subject' => $vapidSubject,
                'publicKey' => $vapidPublicKey,
                'privateKey' => $vapidPrivateKey,
            ]
        ];

        $this->webPush = new \Minishlink\WebPush\WebPush($auth);
        $this->db = $db;
    }

    public function saveSubscription($subscriptionData, $userAgent = '', $url = '')
    {
        $endpoint = $subscriptionData['endpoint'];
        $p256dh = $subscriptionData['keys']['p256dh'] ?? '';
        $auth = $subscriptionData['keys']['auth'] ?? '';

        // Проверяем, есть ли уже такая подписка
        $stmt = $this->db->prepare("
            SELECT id FROM push_subscriptions 
            WHERE endpoint = ? 
            LIMIT 1
        ");
        $stmt->execute([$endpoint]);

        if ($stmt->fetch()) {
            // Обновляем существующую
            $stmt = $this->db->prepare("
                UPDATE push_subscriptions 
                SET p256dh = ?, auth = ?, user_agent = ?, last_seen = NOW() 
                WHERE endpoint = ?
            ");
            return $stmt->execute([$p256dh, $auth, $userAgent, $endpoint]);
        } else {
            // Создаём новую
            $stmt = $this->db->prepare("
                INSERT INTO push_subscriptions 
                (endpoint, p256dh, auth, user_agent, subscribe_url, created_at, last_seen) 
                VALUES (?, ?, ?, ?, ?, NOW(), NOW())
            ");
            return $stmt->execute([$endpoint, $p256dh, $auth, $userAgent, $url]);
        }
    }

    public function sendNotification($title, $body, $url = '', $icon = '', $badge = '')
    {
        // Получаем все активные подписки
        $stmt = $this->db->prepare("
            SELECT endpoint, p256dh, auth 
            FROM push_subscriptions 
            WHERE is_active = 1
        ");
        $stmt->execute();
        $subscriptions = $stmt->fetchAll(PDO::FETCH_ASSOC);

        $payload = json_encode([
            'title' => $title,
            'body' => $body,
            'url' => $url,
            'icon' => $icon,
            'badge' => $badge,
            'timestamp' => time()
        ]);

        $successCount = 0;
        $failedEndpoints = [];

        foreach ($subscriptions as $subscription) {
            $webPushSubscription = \Minishlink\WebPush\Subscription::create([
                'endpoint' => $subscription['endpoint'],
                'keys' => [
                    'p256dh' => $subscription['p256dh'],
                    'auth' => $subscription['auth']
                ]
            ]);

            $report = $this->webPush->sendOneNotification(
                $webPushSubscription,
                $payload
            );

            if ($report->isSuccess()) {
                $successCount++;
            } else {
                $failedEndpoints[] = $subscription['endpoint'];
                
                // Если подписка больше не валидна, помечаем как неактивную
                if ($report->getResponse() && $report->getResponse()->getStatusCode() === 410) {
                    $this->deactivateSubscription($subscription['endpoint']);
                }
            }
        }

        return [
            'success_count' => $successCount,
            'failed_endpoints' => $failedEndpoints,
            'total_sent' => count($subscriptions)
        ];
    }

    public function deactivateSubscription($endpoint)
    {
        $stmt = $this->db->prepare("
            UPDATE push_subscriptions 
            SET is_active = 0 
            WHERE endpoint = ?
        ");
        return $stmt->execute([$endpoint]);
    }

    public function getSubscriptionStats()
    {
        $stmt = $this->db->query("
            SELECT 
                COUNT(*) as total,
                COUNT(CASE WHEN is_active = 1 THEN 1 END) as active,
                COUNT(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as week_new
            FROM push_subscriptions
        ");
        
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
}

SQL для создания таблицы подписок:

CREATE TABLE `push_subscriptions` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `endpoint` varchar(500) NOT NULL,
    `p256dh` varchar(255) DEFAULT NULL,
    `auth` varchar(255) DEFAULT NULL,
    `user_agent` text,
    `subscribe_url` varchar(255) DEFAULT NULL,
    `is_active` tinyint(1) DEFAULT 1,
    `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `last_seen` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `endpoint` (`endpoint`),
    KEY `is_active` (`is_active`),
    KEY `created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
💡
Оптимизация: Регулярно чистите неактивные подписки. Я настраиваю cron-задачу, которая раз в месяц удаляет подписки старше 6 месяцев без активности. Подробнее про настройку cron читайте в моей статье про автоматические задачи.

Интеграция с популярными CMS

За годы практики я интегрировал push-уведомления с разными CMS. Расскажу про самые популярные варианты.

WordPress

Для WordPress я создаю плагин или добавляю функциональность в functions.php темы:

// functions.php
function webfull_push_init() {
    // Регистрируем эндпоинты REST API
    add_action('rest_api_init', function () {
        register_rest_route('webfull/v1', '/push-subscription', [
            'methods' => 'POST',
            'callback' => 'webfull_save_push_subscription',
            'permission_callback' => '__return_true'
        ]);
    });

    // Добавляем скрипты на фронтенд
    add_action('wp_enqueue_scripts', 'webfull_push_scripts');
    
    // Хук для отправки уведомлений при публикации поста
    add_action('publish_post', 'webfull_send_new_post_notification');
}
add_action('init', 'webfull_push_init');

function webfull_push_scripts() {
    wp_enqueue_script('webfull-push', get_template_directory_uri() . '/js/push.js', [], '1.0', true);
    wp_localize_script('webfull-push', 'webfullPush', [
        'apiUrl' => home_url('/wp-json/webfull/v1/push-subscription'),
        'publicKey' => get_option('webfull_vapid_public_key'),
        'nonce' => wp_create_nonce('wp_rest')
    ]);
}

function webfull_save_push_subscription(WP_REST_Request $request) {
    global $wpdb;
    
    $subscription = $request->get_param('subscription');
    $userAgent = $request->get_param('user_agent');
    $url = $request->get_param('url');
    
    if (!$subscription || !isset($subscription['endpoint'])) {
        return new WP_Error('invalid_subscription', 'Неверные данные подписки', ['status' => 400]);
    }
    
    $endpoint = $subscription['endpoint'];
    $p256dh = $subscription['keys']['p256dh'] ?? '';
    $auth = $subscription['keys']['auth'] ?? '';
    
    $result = $wpdb->replace(
        $wpdb->prefix . 'push_subscriptions',
        [
            'endpoint' => $endpoint,
            'p256dh' => $p256dh,
            'auth' => $auth,
            'user_agent' => $userAgent,
            'subscribe_url' => $url,
            'last_seen' => current_time('mysql')
        ],
        ['%s', '%s', '%s', '%s', '%s', '%s']
    );
    
    if ($result === false) {
        return new WP_Error('db_error', 'Ошибка сохранения в БД', ['status' => 500]);
    }
    
    return ['success' => true, 'message' => 'Подписка сохранена'];
}

function webfull_send_new_post_notification($post_id) {
    $post = get_post($post_id);
    
    // Отправляем только для опубликованных постов
    if ($post->post_status !== 'publish' || $post->post_type !== 'post') {
        return;
    }
    
    // Не отправляем для старых постов (избегаем спама при массовом импорте)
    if (strtotime($post->post_date) < strtotime('-1 hour')) {
        return;
    }
    
    $title = 'Новая статья: ' . get_the_title($post_id);
    $excerpt = wp_trim_words(get_the_excerpt($post_id), 15);
    $url = get_permalink($post_id);
    
    // Здесь используем наш WebPushManager
    $pushManager = new WebPushManager(
        get_option('webfull_vapid_public_key'),
        get_option('webfull_vapid_private_key'),
        get_option('webfull_vapid_subject'),
        $wpdb
    );
    
    $result = $pushManager->sendNotification($title, $excerpt, $url);
    
    // Логируем результат
    error_log("Push уведомления отправлены: {$result['success_count']} из {$result['total_sent']}");
}

Битрикс

В Битрикс я обычно создаю компонент или добавляю функциональность через init.php:

// /bitrix/php_interface/init.php
use Bitrix\Main\EventManager;

// Регистрируем обработчики событий
$eventManager = EventManager::getInstance();

// Отправляем push при добавлении новости
$eventManager->addEventHandler('iblock', 'OnAfterIBlockElementAdd', 'WebfullPushOnNewsAdd');

function WebfullPushOnNewsAdd(&$arFields) {
    if ($arFields['IBLOCK_ID'] == 1 && $arFields['ACTIVE'] == 'Y') { // ID инфоблока новостей
        $title = 'Новая новость: ' . $arFields['NAME'];
        $preview = strip_tags($arFields['PREVIEW_TEXT']);
        $url = 'https://' . $_SERVER['HTTP_HOST'] . '/news/' . $arFields['CODE'] . '/';
        
        // Отправляем push
        $pushManager = new WebPushManager(
            \Bitrix\Main\Config\Option::get('main', 'vapid_public_key'),
            \Bitrix\Main\Config\Option::get('main', 'vapid_private_key'),
            \Bitrix\Main\Config\Option::get('main', 'vapid_subject'),
            \Bitrix\Main\Application::getConnection()
        );
        
        $pushManager->sendNotification($title, $preview, $url);
    }
}

// Ajax-обработчик для подписки
if ($_POST['action'] == 'save_push_subscription' && check_bitrix_sessid()) {
    $subscription = json_decode($_POST['subscription'], true);
    
    if ($subscription && isset($subscription['endpoint'])) {
        // Сохраняем подписку в БД
        $connection = \Bitrix\Main\Application::getConnection();
        
        $connection->query("
            INSERT INTO b_push_subscriptions 
            (endpoint, p256dh, auth, user_agent, subscribe_url, date_create) 
            VALUES ('{$subscription['endpoint']}', '{$subscription['keys']['p256dh']}', 
                    '{$subscription['keys']['auth']}', '{$_SERVER['HTTP_USER_AGENT']}', 
                    '{$_SERVER['HTTP_REFERER']}', NOW())
            ON DUPLICATE KEY UPDATE 
            p256dh = VALUES(p256dh), auth = VALUES(auth), date_update = NOW()
        ");
        
        echo json_encode(['success' => true]);
    } else {
        echo json_encode(['error' => 'Неверные данные подписки']);
    }
    die();
}

Честно говоря, с Битрикс иногда возникают нюансы из-за особенностей его архитектуры. Но в целом push-уведомления работают стабильно. Если планируете серьёзный проект на Битрикс, рекомендую мои услуги по поддержке Битрикс.

Оптимизация доставляемости и аналитика

Просто отправлять уведомления недостаточно — нужно отслеживать их эффективность и оптимизировать. За годы работы я выработал несколько правил.

Во-первых, всегда отслеживаю статистику доставки:

public function sendNotificationWithTracking($title, $body, $url = '', $campaignId = null) {
    $startTime = microtime(true);
    
    // Логируем начало отправки
    $this->logCampaign($campaignId, 'started', [
        'title' => $title,
        'recipients_count' => $this->getActiveSubscriptionsCount()
    ]);
    
    $result = $this->sendNotification($title, $body, $url);
    
    $duration = microtime(true) - $startTime;
    
    // Логируем результаты
    $this->logCampaign($campaignId, 'completed', [
        'success_count' => $result['success_count'],
        'failed_count' => count($result['failed_endpoints']),
        'total_sent' => $result['total_sent'],
        'duration' => round($duration, 2),
        'success_rate' => round(($result['success_count'] / $result['total_sent']) * 100, 2)
    ]);
    
    // Деактивируем неработающие подписки
    foreach ($result['failed_endpoints'] as $endpoint) {
        $this->deactivateSubscription($endpoint);
    }
    
    return $result;
}

private function logCampaign($campaignId, $status, $data) {
    if (!$campaignId) return;
    
    $stmt = $this->db->prepare("
        INSERT INTO push_campaigns_log 
        (campaign_id, status, data, created_at) 
        VALUES (?, ?, ?, NOW())
    ");
    $stmt->execute([$campaignId, $status, json_encode($data)]);
}

Во-вторых, тестирую время отправки. На практике лучше всего работают уведомления:

В-третьих, сегментирую аудиторию по поведению:

-- Активные пользователи (заходили за последнюю неделю)
SELECT endpoint FROM push_subscriptions 
WHERE last_seen >= DATE_SUB(NOW(), INTERVAL 7 DAY) 
AND is_active = 1;

-- Новые подписчики (подписались за последние 3 дня)
SELECT endpoint FROM push_subscriptions 
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY) 
AND is_active = 1;

-- Пользователи с определённых страниц
SELECT endpoint FROM push_subscriptions 
WHERE subscribe_url LIKE '%/blog/%' 
AND is_active = 1;
⚠️
Частота отправки: Не спамьте! Я рекомендую максимум 1-2 уведомления в день. Лучше реже, но качественнее. Частые уведомления приводят к массовым отписками.

Для аналитики я создаю простую админку:

class PushAnalytics {
    public function getDashboardData() {
        $stats = [];
        
        // Общая статистика подписок
        $stmt = $this->db->query("
            SELECT 
                COUNT(*) as total_subscriptions,
                COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_subscriptions,
                COUNT(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as new_week,
                COUNT(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 END) as new_month
            FROM push_subscriptions
        ");
        $stats['subscriptions'] = $stmt->fetch(PDO::FETCH_ASSOC);
        
        // Статистика по кампаниям за месяц
        $stmt = $this->db->query("
            SELECT 
                DATE(created_at) as date,
                COUNT(*) as campaigns_count,
                AVG(JSON_EXTRACT(data, '$.success_rate')) as avg_success_rate,
                SUM(JSON_EXTRACT(data, '$.success_count')) as total_delivered
            FROM push_campaigns_log 
            WHERE status = 'completed' 
            AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
            GROUP BY DATE(created_at)
            ORDER BY date DESC
        ");
        $stats['campaigns'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
        
        // Топ источники подписок
        $stmt = $this->db->query("
            SELECT 
                subscribe_url,
                COUNT(*) as subscriptions_count
            FROM push_subscriptions 
            WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
            GROUP BY subscribe_url 
            ORDER BY subscriptions_count DESC 
            LIMIT 10
        ");
        $stats['top_sources'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
        
        return $stats;
    }
}

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

Push-уведомления попадают под GDPR, поэтому нужно правильно обрабатывать персональные данные. Я всегда добавляю несколько обязательных элементов.

Во-первых, чёткое согласие пользователя с объяснением, что именно он разрешает:

<div class="gdpr-notice">
    <p>Нажимая "Разрешить", вы соглашаетесь получать push-уведомления о новых статьях и важных обновлениях. 
       Мы храним данные вашей подписки до её отмены. Вы можете отписаться в любой момент.</p>
    <p><a href="/privacy-policy">Политика конфиденциальности</a></p>
</div>

Во-вторых, возможность управления подпиской и удаления данных:

public function unsubscribeByToken($token) {
    // Находим подписку по токену
    $stmt = $this->db->prepare("
        SELECT endpoint FROM push_subscriptions 
        WHERE MD5(CONCAT(endpoint, 'secret_salt')) = ? 
        AND is_active = 1
    ");
    $stmt->execute([$token]);
    $subscription = $stmt->fetch();
    
    if ($subscription) {
        // Удаляем подписку (или помечаем как неактивную)
        $stmt = $this->db->prepare("
            DELETE FROM push_subscriptions 
            WHERE endpoint = ?
        ");
        return $stmt->execute([$subscription['endpoint']]);
    }
    
    return false;
}

public function generateUnsubscribeLink($endpoint) {
    $token = md5($endpoint . 'secret_salt');
    return 'https://example.com/unsubscribe?token=' . $token;
}

В-третьих, шифрование чувствительных данных в базе:

// Шифруем ключи подписки
private function encryptSubscriptionKey($key) {
    $encryptionKey = base64_decode($_ENV['ENCRYPTION_KEY']);
    $iv = random_bytes(16);
    $encrypted = openssl_encrypt($key, 'AES-256-CBC', $encryptionKey, 0, $iv);
    return base64_encode($iv . $encrypted);
}

private function decryptSubscriptionKey($encryptedKey) {
    $encryptionKey = base64_decode($_ENV['ENCRYPTION_KEY']);
    $data = base64_decode($encryptedKey);
    $iv = substr($data, 0, 16);
    $encrypted = substr($data, 16);
    return openssl_decrypt($encrypted, 'AES-256-CBC', $encryptionKey, 0, $iv);
}

Честно говоря, с GDPR лучше перебдеть, чем недобдеть. Штрафы могут быть серьёзными — до 4% от оборота компании.

Тестирование и отладка push-уведомлений

Отладка push-уведомлений — отдельная наука. За годы практики я накопил несколько приёмов для быстрого поиска проблем.

Первым делом всегда проверяю консоль браузера. Большинство ошибок видно сразу:

// Добавляю подробное логирование в Service Worker
self.addEventListener('push', function(event) {
    console.log('[SW] Push событие получено:', event);
    
    let notificationData = {
        title: 'Заголовок по умолчанию',
        body: 'Текст по умолчанию',
        icon: '/default-icon.png'
    };
    
    if (event.data) {
        try {
            notificationData = event.data.json();
            console.log('[SW] Данные уведомления:', notificationData);
        } catch (error) {
            console.error('[SW] Ошибка парсинга данных:', error);
        }
    }
    
    const options = {
        body: notificationData.body,
        icon: notificationData.icon,
        badge: notificationData.badge,
        data: {
            url: notificationData.url,
            timestamp: Date.now()
        }
    };
    
    event.waitUntil(
        self.registration.showNotification(notificationData.title, options)
            .then(() => {
                console.log('[SW] Уведомление показано успешно');
            })
            .catch(error => {
                console.error('[SW] Ошибка показа уведомления:', error);
            })
    );
});

Для тестирования отправки я создаю отдельную страницу админки:

// test-push.php (доступ только админам!)
if (!current_user_can('manage_options')) {
    wp_die('Доступ запрещён');
}

if ($_POST['send_test_push']) {
    $pushManager = new WebPushManager(/* параметры */);
    
    $result = $pushManager->sendNotification(
        $_POST['test_title'] ?: 'Тестовое уведомление',
        $_POST['test_body'] ?: 'Это тестовое push-уведомление',
        $_POST['test_url'] ?: home_url(),
        $_POST['test_icon'] ?: get_site_icon_url()
    );
    
    echo "<div class='notice notice-success'>";
    echo "Отправлено: {$result['success_count']} из {$result['total_sent']}";
    if (!empty($result['failed_endpoints'])) {
        echo "<br>Ошибки: " . count($result['failed_endpoints']);
    }
    echo "</div>";
}
?>

<form method="post">
    <table class="form-table">
        <tr>
            <th>Заголовок</th>
            <td><input type="text" name="test_title" class="regular-text" /></td>
        </tr>
        <tr>
            <th>Текст</th>
            <td><textarea name="test_body" rows="3" class="large-text"></textarea></td>
        </tr>
        <tr>
            <th>URL</th>
            <td><input type="url" name="test_url" class="regular-text" /></td>
        </tr>
        <tr>
            <th>Иконка</th>
            <td><input type="url" name="test_icon" class="regular-text" /></td>
        </tr>
    </table>
    <p class="submit">
        <input type="submit" name="send_test_push" class="button-primary" value="Отправить тест" />
    </p>
</form>

Для отладки VAPID-ключей использую онлайн-инструменты или создаю простой скрипт:

// debug-vapid.php
function testVapidKeys($publicKey, $privateKey) {
    try {
        // Проверяем формат ключей
        if (strlen(base64_decode($publicKey)) !== 65) {
            throw new Exception('Неверный формат публичного ключа');
        }
        
        if (strlen(base64_decode($privateKey)) !== 32) {
            throw new Exception('Неверный формат приватного ключа');
        }
        
        // Проверяем, что ключи связаны
        $testAuth = [
            'VAPID' => [
                'subject' => 'mailto:test@example.com',
                'publicKey' => $publicKey,
                'privateKey' => $privateKey,
            ]
        ];
        
        $webPush = new \Minishlink\WebPush\WebPush($testAuth);
        
        echo "✅ VAPID ключи корректны\n";
        return true;
        
    } catch (Exception $e) {
        echo "❌ Ошибка VAPID ключей: " . $e->getMessage() . "\n";
        return false;
    }
}

// Использование
testVapidKeys($publicKey, $privateKey);

А вот самые частые проблемы, с которыми я сталкиваюсь:

  1. Service Worker не регистрируется — обычно проблема с HTTPS или путём к файлу
  2. Подписка не создаётся — чаще всего неверный публичный VAPID-ключ
  3. Уведомления не приходят — проблемы с приватным ключом или endpoint'ами
  4. Показывается не то содержимое — ошибка в обработке payload в Service Worker

Производительность и масштабирование

Когда подписчиков становится много (у меня были проекты с 100К+ подписок), нужно думать о производительности. Отправка 100 тысяч уведомлений может занять часы, если делать это неправильно.

Я использую очереди для массовой отправки:

class PushQueue {
    private $redis;
    private $webPush;
    
    public function __construct($redis, $webPush) {
        $this->redis = $redis;
        $this->webPush = $webPush;
    }
    
    public function addCampaign($title, $body, $url, $segments = []) {
        $campaignId = uniqid('campaign_');
        
        // Получаем подписчиков по сегментам
        $subscriptions = $this->getSubscriptionsBySegments($segments);
        
        // Разбиваем на батчи по 100 штук
        $batches = array_chunk($subscriptions, 100);
        
        foreach ($batches as $batchIndex => $batch) {
            $job = [
                'campaign_id' => $campaignId,
                'batch_index' => $batchIndex,
                'title' => $title,
                'body' => $body,
                'url' => $url,
                'subscriptions' => $batch
            ];
            
            // Добавляем задачу в очередь Redis
            $this->redis->lpush('push_queue', json_encode($job));
        }
        
        return [
            'campaign_id' => $campaignId,
            'batches_count' => count($batches),
            'total_recipients' => count($subscriptions)
        ];
    }
    
    public function processQueue($maxJobs = 10) {
        $processedJobs = 0;
        
        while ($processedJobs < $maxJobs) {
            $jobData = $this->redis->brpop(['push_queue'], 1);
            
            if (!$jobData) {
                break; // Очередь пуста
            }
            
            $job = json_decode($jobData[1], true);
            
            try {
                $this->processBatch($job);
                $processedJobs++;
                
                // Логируем успех
                error_log("Обработан batch {$job['batch_index']} кампании {$job['campaign_id']}");
                
            } catch (Exception $e) {
                // При ошибке возвращаем задачу в очередь с задержкой
                $job['retry_count'] = ($job['retry_count'] ?? 0) + 1;
                
                if ($job['retry_count'] < 3) {
                    sleep(5); // Задержка перед повтором
                    $this->redis->lpush('push_queue', json_encode($job));
                }
                
                error_log("Ошибка обработки batch: " . $e->getMessage());
            }
        }
        
        return $processedJobs;
    }
    
    private function processBatch($job) {
        $payload = json_encode([
            'title' => $job['title'],
            'body' => $job['body'],
            'url' => $job['url'],
            'timestamp' => time()
        ]);
        
        foreach ($job['subscriptions'] as $subscription) {
            $webPushSubscription = \Minishlink\WebPush\Subscription::create([
                'endpoint' => $subscription['endpoint'],
                'keys' => [
                    'p256dh' => $subscription['p256dh'],
                    'auth' => $subscription['auth']
                ]
            ]);
            
            $this->webPush->queueNotification($webPushSubscription, $payload);
        }
        
        // Отправляем весь batch за раз
        $reports = $this->webPush->flush();
        
        // Обрабатываем результаты
        foreach ($reports as $report) {
            if (!$report->isSuccess() && $report->getResponse()) {
                $statusCode = $report->getResponse()->getStatusCode();
                
                // 410 Gone означает, что подписка больше не валидна
                if ($statusCode === 410) {
                    $this->deactivateSubscription($report->getEndpoint());
                }
            }
        }
    }
}

Для обработки очереди настраиваю cron-задачу:

# Crontab - запускаем каждую минуту
* * * * * /usr/bin/php /path/to/process-push-queue.php >> /var/log/push-queue.log 2>&1
// process-push-queue.php
connect('127.0.0.1', 6379);

$webPush = new \Minishlink\WebPush\WebPush([
    'VAPID' => [
        'subject' => $_ENV['VAPID_SUBJECT'],
        'publicKey' => $_ENV['VAPID_PUBLIC_KEY'],
        'privateKey' => $_ENV['VAPID_PRIVATE_KEY'],
    ]
]);

$queue = new PushQueue($redis, $webPush);

// Обрабатываем до 50 задач за раз
$processed = $queue->processQueue(50);

echo "Обработано задач: $processed\n";
💡
Оптимизация сервера: Для больших объёмов рекомендую увеличить лимиты PHP: memory_limit до 512M, max_execution_time до 300 секунд. Подробнее про настройку лимитов читайте в статье про лимиты памяти PHP и MySQL.

При очень больших объёмах (миллионы подписчиков) стоит рассмотреть специализированные сервисы вроде OneSignal или Pusher. Но для большинства проектов собственное решение работает отлично и даёт полный контроль над данными.

На практике я видел, как правильно настроенные push-уведомления кардинально меняют метрики сайта. Один мой клиент — новостной портал — увеличил среднее время на сайте на 40% просто за счёт своевременных push-уведомлений о важных новостях.

Главное — не злоупотреблять частотой отправки и всегда давать пользователям реальную ценность. Push-уведомления — это мощный инструмент, но с ним нужно обращаться аккуратно.

Нужна помощь с настройкой push-уведомлений?

Наши эксперты помогут интегрировать и настроить эффективную систему веб push-уведомлений на вашем сайте.

П
Павел
Веб-разработчик · 10+ лет опыта · Bitrix, WordPress, Laravel

Читайте также

Доработка сайта: что можно улучшить Почему сайт медленно работает и как это исправить Автоматическое тестирование сайта: зачем и как