Webhook простыми словами: почему магазин сам считывает реальный статус заказа
Webhook сообщает вашему магазину, что что-то произошло. Это не доказательство того, что это произошло. Рассказываем, как Paynet подписывает webhook, почему стоит относиться к нему как к напоминанию и считывать платеж заново, а также как проверить подпись на PHP и Node.
Ваш покупатель платит на странице оплаты. По пути обратно на ваш сайт у него пропадает сигнал. Он так и не попадает на вашу страницу благодарности. Оплачен ли заказ?
Это именно та проблема, для решения которой существуют webhook, и в то же время проблема, для решения которой webhook регулярно доверяют больше, чем следует. В этой статье разбираются обе половины: что такое webhook Paynet и почему магазину все равно стоит уточнить.
Что такое webhook
Webhook - это HTTP-запрос, который Paynet отправляет на ваш URL, когда что-то происходит. Вместо того чтобы ваш сервер спрашивал "оплачено ли уже" каждые несколько секунд, Paynet сам стучится к вам, как только ответ известен.
Paynet отправляет webhook при каждом финальном статусе платежа, включая неудачные. И неудавшийся платеж, и платеж, истекший неоплаченным, порождают событие transaction.status.updated, а не только успешные. Возвраты порождают событие payment.refunded.
Запрос отправляется на callback_url, указанный вами для этого платежа. Если вы его не указали, он отправляется на URL webhook, настроенный для домена.
Тело запроса содержит delivery_id, название события event, флаг livemode, timestamp, объект transaction с uuid, вашим order_id, статусом, суммой, возвращенной суммой, валютой, процессором и временными метками, а также объект refund в событиях возврата и объект receipt с идентификатором чека и URL QR-кода, когда выпущен фискальный чек.
Почему магазин сам считывает реальный статус
Вот правило, вокруг которого построен Paynet: относитесь к webhook как к напоминанию и считывайте истину заново, прежде чем отмечать заказ оплаченным.
На это есть три причины.
Отправить запрос на ваш URL может кто угодно. Ваша конечная точка для webhook - это публичный URL в интернете. Без проверки посторонний, угадавший его, может сообщить вашему магазину, что заказ 1042 оплачен. Подпись, о которой ниже, закрывает эту дыру, но повторное считывание статуса закрывает ее второй раз, поскольку ответ в этом случае приходит из аутентифицированного запроса, инициированного вами самими.
Webhook - это снимок момента, а не настоящее время. Доставки могут приходить с опозданием, не по порядку или дважды. Если платеж изменился с момента постановки события в очередь, то тело запроса, которое у вас на руках, устарело.
Статусы не окончательны в том смысле, в каком вы могли бы ожидать. Запоздалое подтверждение от провайдера может перевести платеж из failed или expired в completed. Всегда действуйте на основании последнего прочитанного статуса, а не первого увиденного webhook.
Поэтому процесс такой: проверьте подпись, быстро ответьте 200, исключите дубли по delivery_id, а затем вызовите GET /api/v1/orders/{order_id}/payment со своим API-ключом и действуйте на основании того, что он вернет. Этот эндпоинт привязан к вашему аккаунту, так что магазин может прочитать только собственные заказы, и он возвращает самый решающий платеж для заказа: финальная запись побеждает устаревшую ожидающую.
Именно это делают официальные плагины Paynet для CMS. Webhook подталкивает магазин, магазин считывает истину. Именно поэтому плагин продолжает работать правильно, даже когда доставка потеряна.
Подпись
Каждая доставка несет заголовок X-Paynet-Signature. Это шестнадцатеричный HMAC-SHA256 в нижнем регистре от необработанного тела запроса, вычисленный с помощью вашего секрета webhook. Этот секрет вы найдете в панели управления в настройках webhook домена.
Три детали определяют, действительно ли ваша проверка верна.
Используйте необработанные байты. Вычисляйте HMAC по телу запроса именно так, как оно пришло. Если ваш фреймворк разбирает JSON, а вы затем кодируете его заново, пробелы и порядок ключей могут измениться, и подпись не совпадет. В Express это означает express.raw, а не express.json.
Сравнивайте с постоянным временем выполнения. Используйте hash_equals в PHP или crypto.timingSafeEqual в Node, а не ==. Обычное сравнение раскрывает, какая часть подписи угадана верно.
Принимайте заголовок ротации. При ротации секрета подписи доставки в течение 24 часов несут обе подписи: X-Paynet-Signature под текущим секретом и X-Paynet-Signature-Next под новым. Проверка по любой из них позволяет провести ротацию, не потеряв ни одного события.
PHP
// PHP: raw body, hex HMAC, constant-time compare, then read the truth back.
$raw = file_get_contents('php://input');
$secret = getenv('PAYNET_WEBHOOK_SECRET');
$given = $_SERVER['HTTP_X_PAYNET_SIGNATURE'] ?? '';
$next = $_SERVER['HTTP_X_PAYNET_SIGNATURE_NEXT'] ?? '';
$mine = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($mine, $given) && !hash_equals($mine, $next)) { http_response_code(401); exit; }
$event = json_decode($raw, true);
// dedupe on $event['delivery_id'], then:
$payment = json_decode(file_get_contents(
'https://paynet.am/api/v1/orders/' . rawurlencode($event['transaction']['order_id']) . '/payment',
false, stream_context_create(['http' => ['header' => "X-Paynet-Key: " . getenv('PAYNET_API_KEY')]])
), true);
if (($payment['status'] ?? null) === 'completed') { /* mark the order paid */ }
http_response_code(200);
Node.js
// Node.js (Express): keep the raw body for the HMAC, never a re-encoded one.
app.post('/webhooks/paynet', express.raw({ type: '*/*' }), async (req, res) => {
const mine = crypto.createHmac('sha256', process.env.PAYNET_WEBHOOK_SECRET).update(req.body).digest('hex');
const ok = [req.get('X-Paynet-Signature'), req.get('X-Paynet-Signature-Next')]
.some(h => h && h.length === mine.length && crypto.timingSafeEqual(Buffer.from(h), Buffer.from(mine)));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// dedupe on event.delivery_id, then confirm:
const r = await fetch(`https://paynet.am/api/v1/orders/${encodeURIComponent(event.transaction.order_id)}/payment`,
{ headers: { 'X-Paynet-Key': process.env.PAYNET_API_KEY } });
const payment = await r.json();
if (payment.status === 'completed') { /* mark the order paid */ }
res.sendStatus(200);
});
Храните секрет и API-ключ в переменных окружения. Ни тому ни другому не место в вашем репозитории или в коде браузера.
Повторные попытки и что ваша конечная точка им должна
Paynet ждет 2xx-ответ 10 секунд. Он не следует перенаправлениям и отказывается доставлять на приватные адреса. Если доставка не удалась, она повторяется три раза: через 1 минуту, через 5 минут и через 30 минут.
Отсюда следуют два вывода.
Отвечайте быстро, работайте потом. Проверьте, зафиксируйте, верните 200. Если вы выполняете заказ, отправляете письмо и вызываете свою бухгалтерскую систему прямо внутри запроса, рано или поздно вы превысите 10 секунд и получите повторные попытки для работы, которую уже выполнили. Ставьте эту часть в очередь.
Ожидайте дубликаты. Повторная попытка может произойти из-за того, что ваш ответ был медленным, а не потому что вы никогда не получали событие. Поэтому одно и то же событие может прийти дважды, и оба раза оно будет нести один и тот же delivery_id. Сохраняйте delivery_id и игнорируйте те, что вы уже обработали. Это и есть идемпотентность в данном контексте, и это самая полезная строчка кода в обработчике webhook.
Если доставка для завершенного или возвращенного платежа окончательно не удалась, Paynet отправляет вам письмо, не чаще одного раза в шесть часов на домен. Это письмо означает, что реальные деньги переместились, а ваш магазин может об этом не знать.
Тестовый режим и реальный режим
Каждое событие несет livemode. Тестовые платежи, совершенные с ключом sk_test_ или через провайдера Sandbox, вызывают webhook с livemode: false, так что весь путь можно проверить от начала до конца, прежде чем в дело вступит хоть одна реальная карта.
Используйте этот флаг как средство защиты. Если ваша система заказов в продакшене когда-либо получит событие с livemode: false, значит что-то настроено неверно, и лучше громко отклонить его, чем отметить реальный заказ оплаченным на основании тестового события.
Инструменты в панели управления
Вам не нужно гадать, работают ли доставки.
Страница "Webhook" показывает, у скольких доменов настроен URL webhook и сколько доставок прошло успешно, а сколько нет за последние семь дней. Журнал доставок перечисляет каждую попытку с событием, доменом, заказом, результатом и временем, включая те, что вообще не получили ответа. Любую неудачную доставку можно повторить прямо оттуда.
Есть также кнопка "Отправить тестовый webhook" в настройках webhook домена. Она ставит доставку в очередь в течение нескольких секунд, а результат появляется в журнале. Используйте ее при первом развертывании своей конечной точки, а затем снова после любого изменения на вашем сервере, TLS-сертификате или брандмауэре.
Короткий чек-лист для правильного обработчика
- Считайте необработанное тело запроса до того, как что-либо его разберет.
- Вычислите шестнадцатеричный дайджест HMAC-SHA256 с вашим секретом webhook.
- Сравните с постоянным временем выполнения с
X-Paynet-Signature, а также сX-Paynet-Signature-Next. - Отклоните с кодом 401, если ни один не совпал.
- Исключите дубли по
delivery_idи остановитесь, если уже видели это событие. - Немедленно верните 200.
- В фоне вызовите эндпоинт статуса заказа и действуйте на основании возвращенного статуса.
- Никогда не отмечайте заказ оплаченным только на основании тела webhook.
Что делать дальше
- Задайте URL webhook для своего домена в панели управления или отправляйте
callback_urlпри создании платежа. - Реализуйте описанный выше обработчик и сохраните секрет webhook в переменной окружения.
- Нажмите "Отправить тестовый webhook" и проверьте журнал доставок.
- Совершите тестовый платеж в sandbox и убедитесь, что ваш заказ переходит в статус оплаченного через считывание, а не через уведомление.
- Прочитайте разделы о webhook и жизненном цикле в справочнике по API, а также руководство для разработчиков для остальной части интеграции.
Если вы предпочитаете не писать ничего из этого самостоятельно, готовые плагины для магазинов уже это реализуют.
Любые упомянутые названия провайдеров или платформ принадлежат их владельцам. Paynet - независимый платежный шлюз.