Как не потратить полдня, прикручивая Юмани-оплату к сайту

от автора

Спойлер: кажется SHA1 устарел, а туториалы этого еще не знают

Привет всем.
Для контекста: я не программист. Я гуманитарий и до безумия боюсь цифрового неравенства, которое уже наступает. Поэтому, вооружившись AI‑агентом (Cursor) и бесплатными Claude, Gemini, DeepSeek, пытаюсь быть в тренде (некоторые называют это вайб‑кодингом). Признаться, я тоже думал, что это что‑то легкое, «вайбовое» — главное, всё внятно описать, что ты хочешь. Но по факту в том, что происходит, всё равно приходится разбираться самому.

Свежий пример моих «грабель» — решил прикрутить оплату ЮМани.
Казалось бы, ЮМани, всё придумано до меня. Но есть нюансы…

Что хотел сделать
Пользователь нажимает «Купить» → переходит на форму оплаты ЮМани → платит → ЮМани уведомляет мой сервер → сервер создаёт одноразовую ссылку на скачивание → пользователь получает файл. Все счастливы.

Насколько я понял и разобрался, то ЮМани шлет HTTP‑уведомления на мой сервер когда приходит платёж. Я указываю URL своего PHP‑скрипта в настройках кошелька — и при каждой оплате ЮМани делает POST‑запрос на этот адрес с данными о платеже.

То есть задача сводится к тому, что нужен скрипт, который принимает запрос, проверяет подпись (чтобы убедиться что это реально ЮМани, а не кто‑то левый), и если всё ок — записывает покупку в базу данных.

Пользователь тем временем попадает на страницу /success.php, нажимает «Проверить оплату» — страница опрашивает базу данных и если запись есть — выдаёт одноразовую ссылку на скачивание.

Проблема 1 — скрипт не отвечал

Когда я открыл адрес скрипта в браузере, получил ошибку 405 Method Not Allowed.

Паниковать еще рано! Оказалось, что это нормально — скрипт принимает только POST-запросы (которые шлёт ЮМани), а браузер открывает через GET. Сказал Cursor‑у добавить обработку GET с ответом «OK» — чисто для ручной проверки в браузере. ЮМани всегда шлёт POST и только POST.

Проблема 2 — уведомления доходят, но не записываются

После подсказки от Claude добавил логирование, чтобы смотреть по логам, что происходит. По логам уведомления приходят, но скрипт пишет «invalid sha1_hash». Это блин, что такое? Оказалось, что скрипт читал подпись из поля «sha1_hash», а ЮМани присылает в поле «sign» (зачем?!). Поменял название поля — в логе «received» что‑то появилось. Но хеши всё равно не совпадали.

Проблема 3 — неправильный алгоритм. Главная

Пока не полез в документацию ЮМани ничего не выходило. В общем оказалось, что я использовал в скрипте старый протокол… Скрипт считал подпись старым способом — SHA1 от строки параметров через & в фиксированном порядке.

А сейчас подпись — это HMAC‑SHA256 от URL‑кодированной строки всех параметров уведомления кроме sign. Параметры отсортированы по алфавиту.

То есть отличие от скрипта:
— Не SHA1, а HMAC‑SHA256 — принципиально другой алгоритм
— Параметры сортируются по алфавиту (раньше был фиксированный порядок)

Насколько я понял ЮМани обновили протокол — теперь только «sign» и HMAC‑SHA256.

Вот итоговый код проверки подписи:

// Берём все POST параметры кроме 'sign'$params = $_POST;$receivedSign = (string)($params['sign'] ?? '');unset($params['sign']);// Сортируем по алфавитуksort($params);// Собираем строку key=urlencoded_value&key=urlencoded_value$parts = [];foreach ($params as $key => $value) {    $parts[] = $key . '=' . rawurlencode((string)$value);}$hashString = implode('&', $parts);// Считаем HMAC-SHA256 с секретным ключом из настроек ЮMoney$calculatedSign = hash_hmac('sha256', $hashString, YOOMONEY_SECRET);// Сравниваем через hash_equals (защита от timing attack)if (!hash_equals($calculatedSign, $receivedSign)) {    // Подпись не совпала — это точно не ЮMoney, можно смело 400    http_response_code(400);    exit;}

Проблема 4 — card‑incoming

Но и это еще не все.
Провёл тестовый платёж картой. Деньги списались, но запись в БД не появилась.
Смотрю лог: YooMoney skip: notification rejected, type=card-incoming

Скрипт принимал только «p2p‑incoming» (перевод из кошелька), а оплата картой приходит как «card‑incoming». Попросил Cursor добавить оба типа:

$notification_type = $_POST['notification_type'] ?? '';$allowedTypes = ['p2p-incoming', 'card-incoming'];if (!in_array($notification_type, $allowedTypes)) {    // Подпись верная — запрос точно от ЮМани.    // Просто этот тип мы не обрабатываем (может быть новый тип в будущем).    // Отвечаем 200, чтобы ЮМани не слала повторы и не считала сервер упавшим.    http_response_code(200);    exit;}

Фух, блин, вроде заработало. Перевел сам себе 10 рублей. Победа!

В итоге, если кому понадобится — краткая инфо.

SHA1 в ЮМани не работает — теперь «sign» и HMAC‑SHA256. Если нашел инструкцию с «sha1_hash» и фиксированным порядком полей — он устарел.

«p2p‑incoming» и «card‑incoming» — надо оба, и карта, и кошелек.

Если подпись не совпала — отвечаем «400» (это чужой запрос, не ЮМани). Но если подпись верна, а тип платежа тебе просто не подходит — отвечаем «200», чтобы ЮМани не считала твой сервер упавшим и не отключила уведомления.

Логирование — при любом удобном случае. Нужен error_log в скрипте, чтобы реально смотреть, что приходит.

Тестовые уведомления от ЮМани не создают запись в БД — они только проверяют доступность скрипта и правильность подписи.

Ну, вот так…

ссылка на оригинал статьи https://habr.com/ru/articles/1042652/