Александр Некрасов · Автономное руководство

PHP: полный мануал с примерами, функциями и ООП

Разделы этой страницы
  1. Webhook: подпись, повторы и порядок событий
  2. Webhook: сервис сам сообщает о событии
  3. Внешний сервис сам обращается к вашему endpoint
  4. Проверяем подпись до бизнес-действия
  5. Ограничиваем вход и сохраняем исходное тело
  6. Уникальный event_id и допустимый переход состояния
  7. Тесты полезнее ручного «пришло один раз»
  8. Подтверждение доставки и деловая семантика
  9. Практика: проверьте решение, а не только успешный путь
  10. Функции и методы этой темы
  11. Код и руководство с собой
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 36 · Практика

Webhook: подпись, повторы и порядок событий

Принимаем уведомление об оплате так, чтобы подделка и повтор не меняли заказ дважды.

Webhook: сервис сам сообщает о событии

В обычном API-клиенте вы спрашиваете: «Заказ уже оплачен?». С webhook сервис при событии отправляет запрос на ваш адрес. Ваш сайт в этом разговоре принимает запрос. Формат и подтверждение приёма задаёт отправитель.

Что уже пригодится. HTTP-запрос, разбор JSON

Кто начинает разговор
  1. Событие в чужом сервисе
  2. Сервис отправляет POST на ваш URL
  3. Вы читаете исходное тело
  4. Проверяете подпись и структуру
  5. Запоминаете событие и подтверждаете приём

JSON обычно читают из php://input; это не то же самое, что $_POST. Сначала проверяют подлинность исходных байтов по договору поставщика, затем используют поля. Повторная доставка события нормальна.

Рассматриваем поля одного уже принятого событияСамостоятельный пример
<?php
declare(strict_types=1);

$body = '{"event_id":"evt-7","type":"payment.succeeded","order_id":42}';
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
echo $event['event_id'], "\n";
echo $event['order_id'], "\n";
Ожидаемый результат
evt-7
42

Это доверенная учебная строка для знакомства с форматом. Пример ещё не webhook-обработчик: нет подписи, проверки схемы, хранилища повторов и изменения заказа. Не используйте эти две строки как подтверждение реальной оплаты. Полный порядок проверок показан ниже.

Следующая ступень. Следующий шаг — проверка HMAC на исходном теле и идемпотентность: повтор того же event_id не должен повторно выдавать товар.

Внешний сервис сам обращается к вашему endpoint

Наглядная модель

Повтор уведомления не должен оплачивать заказ дважды

Провайдер сообщает событие payment.succeeded. Получение POST ещё не доказывает подлинность или новизну события.

  1. Получить исходное тело

    Ограничиваем размер запроса и сохраняем байты, по которым провайдер вычислил подпись.

  2. Проверить подпись

    Сверяем HMAC безопасным сравнением; проверяем время, если оно предусмотрено протоколом.

  3. Проверить данные события

    Разбираем JSON, идентификаторы, сумму, валюту и связь уведомления с нужным заказом.

  4. Применить один раз

    Уникальный event_id и допустимый переход состояния защищают от дубля и старого события.

  5. Подтвердить принятие

    2xx возвращаем после предусмотренного надёжного сохранения. Повтор не создаёт новый эффект.

Подпись отвечает за подлинность сообщения, а event_id — за повтор. Порядок доставки событий также нельзя считать гарантированным.

При обычном API-вызове ваш код спрашивает состояние. Webhook разворачивает направление: платёжный сервис сообщает, что операция изменилась. Запрос приходит без браузерной сессии и может повториться через минуту или час. Нельзя доверять полю paid=true только потому, что оно находится в JSON. Нужна проверка отправителя по протоколу провайдера, затем проверка содержимого и допустимости изменения заказа.

Подпись часто вычисляется HMAC над точными байтами тела, иногда вместе с меткой времени и другими полями. Алгоритм, кодирование подписи, разделители и формат ключа задаёт документация конкретного сервиса. Hex-строка и base64 от бинарного HMAC — разные значения. Не выбирайте алгоритм произвольно из присланного заголовка и не пересобирайте JSON до проверки: пробелы, порядок ключей и escaping изменяют байты.

HTTPS защищает канал, HMAC проверяет сообщение, временное окно ограничивает давность, а уникальный ID события предотвращает повтор обработки. Один механизм не заменяет остальные. Даже корректно подписанное событие может быть повтором или относиться к другому магазину. Сопоставляйте merchant/account, сумму, валюту и внутренний заказ по серверным данным.

Проверяем подпись до бизнес-действия

Собственный учебный протокол timestamp.bodyСамостоятельный пример
<?php
declare(strict_types=1);

function verifyEvent(string $raw, string $timestamp, string $signature, string $secret, int $now): bool {
    if (!preg_match('/\A[0-9]{10}\z/D',$timestamp)
        || !preg_match('/\A[a-f0-9]{64}\z/D',$signature)
        || abs($now-(int)$timestamp)>300) return false;
    $expected = hash_hmac('sha256',$timestamp.'.'.$raw,$secret);
    return hash_equals($expected,$signature);
}
$raw = '{"eventId":"evt-1","paid":true}';
$timestamp = '1800000000';
$secret = 'test-secret-not-for-production';
$signature = hash_hmac('sha256',$timestamp.'.'.$raw,$secret);
echo verifyEvent($raw,$timestamp,$signature,$secret,1800000001) ? "valid
" : "invalid
";
echo verifyEvent($raw.' ',$timestamp,$signature,$secret,1800000001) ? "valid
" : "invalid
";
echo verifyEvent($raw,$timestamp,$signature,$secret,1800000400) ? "valid
" : "expired
";
Ожидаемый результат
valid
invalid
expired

Это демонстрация явно описанного собственного протокола, не универсальный обработчик Stripe/ЮKassa. Секрет фиксирован только для теста. Добавленный пробел сохраняет JSON-смысл, но меняет подпись байтов.

Ограничиваем вход и сохраняем исходное тело

Чтение небольшого JSON webhook с лимитомПример для указанного окружения
<?php
declare(strict_types=1);

function readWebhookBody(int $maximumBytes = 262144): string {
    $stream = fopen('php://input','rb');
    if ($stream === false) throw new RuntimeException('Cannot read body');
    try {
        $body = stream_get_contents($stream,$maximumBytes+1);
        if ($body === false) throw new RuntimeException('Body read failed');
        if (strlen($body)>$maximumBytes) throw new LengthException('Payload too large');
        return $body;
    } finally {
        fclose($stream);
    }
}
// HTTP-контроллер: метод/Content-Type → readWebhookBody → подпись → json_decode.
// Здесь функция не вызывается: нет входящего HTTP-запроса.

Нужен HTTP SAPI. Веб-сервер также ограничивает тело и время чтения. Заголовок Content-Length не является единственным источником истины. Проверяется синтаксис без чтения внешнего запроса.

Получите сырое тело один раз и передайте его и проверке подписи, и JSON-декодеру. Не вызывайте trim, не заменяйте переводы строк и не удаляйте пробелы. Заголовок подписи проверяют на ожидаемую структуру и допустимую длину, прежде чем выполнять сравнение. Несколько одинаковых заголовков, префиксы версий и ротация ключей требуют правил провайдера, а не догадок.

Сначала проверяется подпись и свежесть, потом JSON и схема события. Это не повод принимать неограниченное тело до HMAC: лимит ставят раньше. Ошибки клиенту сообщают кратко, а в журнал не пишут секрет и полную подпись. Для диагностики достаточно event_id после успешного разбора, безопасного кода и request_id. Ротация ключей может требовать короткого периода проверки двумя заранее доверенными ключами.

Уникальный event_id и допустимый переход состояния

После проверки вставьте event_id в таблицу входящих событий с UNIQUE. В той же транзакции проверьте и измените заказ либо сохраните задание для дальнейшей обработки. Два процесса могут одновременно проверить отсутствие события, поэтому обычная проверка SELECT без уникального ограничения не защищает от дублей. Для уже принятого события обычно возвращают успешное подтверждение, чтобы провайдер прекратил повторять его; точный статус задаётся контрактом.

Порядок доставки не гарантирован: cancelled может прийти раньше created, а старый pending — после paid. Не присваивайте статус слепо. Храните версию/время события, разрешённые переходы и при неопределённости запрашивайте актуальное состояние у провайдера. В финансовых сценариях сумма и валюта из события сверяются с заказом. Сообщение «оплачено» не должно закрыть чужой заказ с таким же внешним номером в другом аккаунте.

Быстрый ответ 2xx отправляют после надёжного сохранения принятого события, а не до него. Дорогую обработку можно вынести в очередь. Если записать не удалось, успешный ответ потеряет событие навсегда; если обработка уже произошла, а ответ потерялся, повтор должен быть безопасен. Получается тот же принцип, что у outbox, только на входной стороне: надёжно принять, затем идемпотентно обработать.

Тесты полезнее ручного «пришло один раз»

Набор проверок включает правильную подпись, подмену одного байта, старую и слишком будущую метку времени, неверную кодировку подписи, неизвестный тип события, лишние поля, неправильную сумму и повтор одного event_id. Отдельно запускают два одновременных запроса с одинаковым событием. Успешный тест должен доказать одну запись/операцию, а не просто два ответа 200.

Для отладки используйте sandbox провайдера и локально сохранённые обезличенные тела. Не повторяйте реальные платёжные уведомления на production ради проверки. Время в тестах передавайте параметром, как в примере: это позволяет проверять границу 300 секунд без ожидания. Размер хранилища принятых событий и срок дедупликации согласуют с длительностью повторов сервиса и требованиями аудита.

Подтверждение доставки и деловая семантика

Даже после успешной проверки подписи обработчик обязан убедиться, что тип события поддерживается этой версией приложения. Неизвестное событие иногда безопасно сохранить и подтвердить, а иногда требуется отказ — это определяется договором интеграции. Ответ 2xx означает принятие доставки, но не обязательно завершение всей бизнес-обработки. Храните эти состояния раздельно, чтобы поддержка могла отличить «провайдер доставил уведомление» от «заказ успешно переведён в оплаченный». Повторно запускаемый обработчик использует сохранённый event_id и не создаёт второй чек или письмо только потому, что первый ответ потерялся.

Практика: проверьте решение, а не только успешный путь

Приложение декодирует JSON, сортирует ключи, кодирует обратно и вычисляет подпись. Почему проверка падает при правильном секрете?

Разбор решения

Подписаны исходные байты, а не абстрактный объект. Нужно вычислять HMAC по исходному телу в точном формате протокола. Декодирование выполняют после проверки. Тест с добавленным пробелом специально демонстрирует эту разницу.

Функции и методы этой темы

Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.

hash_hmacВычислить код аутентификации сообщения с общим секретом.

PHP 5.1.2+ · hash

hash_hmac(string $algo, string $data, string $key, bool $binary = false): string

Принять подлинную учебную запись и отклонить изменение. HMAC вычисляет проверочную строку из точных байтов и отдельного общего ключа. Ключ — третий аргумент; дописать его к тексту и вызвать hash означало бы другую конструкцию. В опыте отправитель и получатель локальные, ключ намеренно учебный; вывод MAC здесь воспроизводим, реальный ключ не публикуют.

Путь конкретных данных
  1. Известны order=42 и учебный общий ключ
  2. HMAC-SHA256 создаёт проверочную строку
  3. Исходная запись принимается; MAC изменённого order=43 отвергается

Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.

Вычислить код аутентификации сообщения с общим секретом.

Параметр / результатКонтракт
algoКриптографический алгоритм из hash_hmac_algos; в примерах sha256.
dataИменно те байты, которые договорились подписывать. HMAC не выполняет JSON-кодирование/канонизацию.
keyОбщий секретный ключ. Это отдельный аргумент, а не текст, дописанный к сообщению. При hex/Base64 конфигурации декодирование оговаривается заранее.
binaryfalse — нижний hex; true — двоичный MAC для дальнейшего согласованного кодирования.
returnСтрока MAC. Она не скрывает содержимое и не содержит ключ для восстановления.
Принять подлинную учебную запись и отклонить изменениеСамостоятельный пример
<?php
declare(strict_types=1);

$known = hash_hmac('sha256', 'order=42', 'local-demo-key');
$received = $known;
echo 'Подпись: ', $known, "\n";
echo hash_equals($known, $received) ? "Исходная запись принята.\n" : "Подпись не совпала.\n";
$changed = hash_hmac('sha256', 'order=43', 'local-demo-key');
echo hash_equals($known, $changed) ? "Изменение принято.\n" : "Изменённая запись отклонена.\n";
Ожидаемый результат
Подпись: 656a184e13c773994fcbc3e7a6030a261700ca9545352c0fb7142f52352e2e1b
Исходная запись принята.
Изменённая запись отклонена.

HMAC не скрывает текст и не предотвращает повтор того же сообщения. При протоколе с событиями добавляют аутентифицированные время/ID и атомарный учёт повторов. Для Base64 вызывайте hash_hmac с true, затем кодируйте сырые байты, а не hex.

В учебном запуске отправитель и получатель находятся в одном файле, сети нет. В endpoint $rawBody получают из ограниченного по размеру HTTP-тела, а $header — из одного определённого протоколом заголовка. Пустой/невалидный заголовок отвергают. true нужен для Base64 от MAC-байтов; Base64 от hex — другая строка.

Проверить Base64-MAC по сырым байтам JSONСамостоятельный пример
<?php
declare(strict_types=1);

$key = 'demo-only-key';
$rawBody = '{"order":42,"paid":true}';
$header = base64_encode(hash_hmac('sha256', $rawBody, $key, true));
$expected = base64_encode(hash_hmac('sha256', $rawBody, $key, true));
echo hash_equals($expected, $header) ? 'signature valid' : 'rejected';
echo "\n";
$changed = '{"order": 42,"paid":true}';
$wrong = base64_encode(hash_hmac('sha256', $changed, $key, true));
echo hash_equals($wrong, $header) ? "Изменённый JSON принят.\n" : "JSON с добавленным пробелом отклонён: подписанные байты отличаются.\n";
Ожидаемый результат
signature valid
JSON с добавленным пробелом отклонён: подписанные байты отличаются.

Вывод показывает результат описанного выше опыта с указанными исходными данными.

HMAC не подтверждает свежесть: перехваченный корректный запрос может быть повторён. Timestamp/nonce/event-id защищают согласно протоколу, а повторную обработку исключают атомарно в постоянном хранилище. Сначала проверяют подлинность, затем декодируют/валидируют бизнес-данные и выполняют действие. Полный маршрут webhook. Не возвращайте ожидаемый MAC или ключ в ответе диагностики.

Ошибки и ограничения. Неизвестный/некриптографический algo — ValueError с 8.0, раньше warning/false; запрет некриптографических алгоритмов действует с 7.2. Одноразовый hash_hmac технически принимает пустой key, но сервис не должен этим маскировать отсутствие секрета; hash_init с HASH_HMAC пустой ключ отвергает. SensitiveParameter в PHP 8.2+ скрывает key в определённых stack traces, а не в ваших echo/log/файлах.

Продолжить: hash_equals, hash_hmac_file, hash_init, hash_hmac_algos. Как выбрать задачу и алгоритм.

Возвращает hex-строку либо сырые байты при binary=true. Алгоритм должен поддерживаться HMAC; неподходящее имя вызывает ValueError. Ключ и точные байты задаёт протокол.

Рядом по задаче. hash_equals сравнивает подписи; password_hash решает другую задачу хранения паролей.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.