Как проверить подпись webhook: HMAC-SHA256 на практике
· 5 мин чтения
Зачем подписывают уведомления о платежах, как устроена подпись HMAC-SHA256, четыре ошибки, из-за которых она не сходится, и защита от повторной отправки.
Webhook — это HTTP-запрос от платёжного сервиса на ваш адрес: «платёж такой-то оплачен». Адрес этот публичный, отправить на него запрос может кто угодно. Поэтому уведомление, которому вы доверяете деньги, обязано быть подписано, а подпись — проверена до того, как заказ уйдёт в отгрузку.
Это касается любого приёма платежей — и через банк, и через шлюз, к которому приходят те, кому не дали эквайринг. Механика подписи от этого не зависит.
Что такое HMAC и почему не просто хэш
Первая мысль — послать вместе с телом его SHA-256. Это не работает: злоумышленник, подделавший тело, точно так же посчитает от него хэш. Хэш подтверждает целостность, но не авторство.
HMAC решает именно вторую задачу. В вычислении участвует секрет, известный только двум сторонам:
signature = HMAC_SHA256(key = секрет, msg = тело_запроса)
Не зная секрета, подпись не подделать. Проверка на вашей стороне — посчитать то же самое от пришедшего тела и сравнить с заголовком.
Как это устроено в takemypay
Секретом служит не сам API-ключ, а его SHA-256 в hex:
X-Signature = HMAC_SHA256(key = hex(SHA256(api_key)), msg = raw_body) → hex
Лишний шаг здесь не украшение. На стороне сервиса хранится только хэш ключа — сам ключ показывается один раз при создании и больше не восстановим. Раз обе стороны знают хэш, он и берётся секретом.
Полностью на Python:
import hmac, hashlib
def verify(raw_body: bytes, signature: str, api_key: str) -> bool:
secret = hashlib.sha256(api_key.encode()).hexdigest()
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Четыре ошибки, из-за которых подпись не сходится
1. Считать не по сырому телу. Самая частая. Фреймворк уже распарсил JSON, вы берёте объект,
сериализуете обратно — и получаете другую строку: поменялся порядок ключей, пропали или добавились
пробелы, экранирование юникода поехало. Подпись считается по байтам, которые пришли, до любого
разбора. В FastAPI это await request.body(), в Express — express.raw() или сохранённый
rawBody, в Django — request.body (но не request.POST).
2. Сравнивать через ==. Обычное сравнение строк выходит на первом несовпавшем байте, и по
времени ответа подпись можно подобрать побайтово. Нужна константная по времени функция:
hmac.compare_digest в Python, crypto.timingSafeEqual в Node.
3. Перепутать hex и base64. Обе стороны должны сойтись в кодировке. Если digest() вернул
байты, а в заголовке hex-строка, сравнение не сойдётся никогда — при том что подпись верна.
4. Не учесть ротацию ключей. Когда у проекта несколько активных ключей, подписано будет
новейшим. Заголовок X-Key-Prefix содержит первые 10 символов использованного ключа — по нему
выбирается, против какого проверять. Без этого шага ротация ломает приём уведомлений ровно в момент
выпуска нового ключа.
Защита от повторной отправки
Верная подпись не означает, что запрос свежий. Перехватив однажды валидное уведомление, его можно отправлять повторно сколько угодно — подпись останется правильной.
Поэтому в подписанное тело кладутся timestamp и nonce. Правило простое: отвергать тело
старше пяти минут. Раз оба поля внутри подписи, подменить их, не сломав её, нельзя.
from datetime import datetime, timezone
ts = datetime.fromisoformat(body["timestamp"])
if (datetime.now(timezone.utc) - ts).total_seconds() > 300:
return False
Ответ должен быть 2xx
Отдельная ловушка, не связанная с криптографией. Доставкой считается ответ 2xx; всё остальное —
повод повторить. Редиректы в это «остальное» входят: 301 и 302 не успех.
Ошибка выглядит невинно — фреймворк сам уводит с /webhook на /webhook/ или с http на https.
В браузере эндпоинт открывается, тесты зелёные, а уведомления не принимаются ни одно. Обнаруживается
это по недоотгруженным заказам через несколько дней, потому что ошибки в логах нет: с точки зрения
вашего сервера ничего не произошло.
Проверяется одной командой:
curl -i -X POST https://ваш-домен/webhook -d '{}'
В первой строке должно быть HTTP/1.1 200, а не 301 или 302.
Цена этой ошибки зависит от способа оплаты. Там, где подтверждение приходит только по сети — например, приём оплаты через СБП, — непринятый webhook означает, что об оплате вы не узнаете вообще ничем другим.
Обработчик обязан быть идемпотентным
Одно и то же уведомление придёт к вам не один раз — например, когда ваш ответ не дошёл из-за сетевого таймаута и сработал повтор. Опираться нужно на идентификатор платежа и на то, что заказ уже обработан, а не на сам факт получения запроса. Иначе повтор превратится во вторую отгрузку.
Расписание повторов, заголовки и полный формат тела — в документации по webhook.
Частые вопросы
Почему нельзя просто прислать SHA-256 от тела запроса?
Потому что хэш подтверждает целостность, но не авторство: злоумышленник, подделавший тело, точно так же посчитает от него хэш. В HMAC участвует секрет, известный только двум сторонам, — не зная его, подпись подделать нельзя.
Что служит секретом в takemypay?
Не сам API-ключ, а его SHA-256 в hex. На стороне сервиса хранится только хэш ключа: сам ключ показывается один раз при создании и больше не восстановим. Секретом поэтому берётся то, что известно обеим сторонам.
Подпись не сходится, хотя ключ верный. Где искать?
Первым делом — в том, по чему считается подпись: почти всегда её считают не по сырому телу, а по
пересобранному JSON, у которого поехал порядок ключей или экранирование. Остальные три причины:
сравнение через == вместо функции с постоянным временем, путаница hex и base64, неучтённая ротация
ключей (какой ключ использован, говорит заголовок X-Key-Prefix).
Зачем timestamp и nonce, если подпись верна?
Верная подпись не означает, что запрос свежий: однажды перехваченное уведомление можно отправлять повторно сколько угодно, и подпись останется правильной. Оба поля лежат внутри подписи, поэтому правило «отвергать тело старше пяти минут» обойти, не сломав подпись, нельзя.
Считается ли ответ 302 доставкой уведомления?
Нет. Доставкой считается только 2xx, всё остальное — повод повторить, и редиректы сюда входят.
Ошибка выглядит невинно: фреймворк сам уводит с /webhook на /webhook/, в браузере эндпоинт
открывается, тесты зелёные, а не принято ни одно уведомление.
Может ли одно и то же уведомление прийти дважды?
Да, это штатное поведение — например, когда ваш ответ не дошёл из-за сетевого таймаута и сработал повтор. Поэтому обработчик обязан опираться на идентификатор платежа и на признак «заказ уже обработан», а не на сам факт получения запроса.
Если приём платежей нужен на практике, а не в теории — кабинет takemypay выдаёт тестовый ключ сразу после регистрации. Вопросы по интеграции — в поддержку.
Читать дальше
· 5мин чтения
Приём оплаты через СБП: как это устроено и что учесть в интеграции
Как проходит платёж по QR-коду СБП, почему подтверждение приходит с задержкой, чем это отличается от карт и какие из этого следуют требования к интеграции.
· 6мин чтения
Комиссия, конвертация и выплаты: сколько стоит приём платежей через шлюз
Как из суммы платежа получается сумма на балансе: комиссия 18% с gross, кто её платит, конвертация по курсу ЦБ с наценкой, порог выплаты и сбор за перевод.
· 5мин чтения
Банк отказал в эквайринге: почему так происходит и что делать дальше
Разбор типичных причин отказа в подключении интернет-эквайринга и работающие способы принимать оплату, пока вопрос с банком не решён.