Надежная обработка платежных вебхуков: идемпотентность и повторы
Продвинутое3 минРедакция Innovialabs
Коротко
Надежный обработчик вебхуков проверяет подпись, сохраняет каждое событие по уникальному ID, меняет статус заказа только по допустимым переходам и регулярно сверяется с API провайдера.
Главное правило
Платежный провайдер может прислать одно событие несколько раз, с задержкой или не в том порядке. Это нормальное поведение: он повторяет отправку, если не получил успешный ответ. Поэтому обработчик должен быть:
- проверяемым — принимать только запросы с верной подписью;
- идемпотентным — повторная обработка того же события ничего не меняет;
- устойчивым к порядку — старое событие не перезаписывает более новый статус;
- сверяемым — состояние заказа можно проверить по API провайдера.
Шаг 1. Проверка подписи
Провайдеры обычно подписывают вебхук: HMAC от тела запроса с общим секретом, подпись в заголовке или параметрах. Конкретная схема у каждого своя (Stripe, Payme, Click и другие описывают ее в документации), но принципы общие:
- проверяйте подпись по сырому телу запроса, до парсинга JSON;
- сравнивайте подписи функцией с постоянным временем;
- отклоняйте слишком старые запросы, если провайдер передает метку времени;
- храните секрет в переменных окружения, а не в коде.
import crypto from "node:crypto";
export function isValidSignature(rawBody: string, signature: string, secret: string) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Шаг 2. Идемпотентность через таблицу событий
У каждого события есть уникальный идентификатор. Сохраняйте его в таблицу с уникальным индексом до бизнес-логики. Если вставка не удалась из-за дубля — событие уже обработано, отвечайте успехом.
CREATE TABLE payment_events (
provider text NOT NULL,
event_id text NOT NULL,
payload jsonb NOT NULL,
processed_at timestamptz,
PRIMARY KEY (provider, event_id)
);
Важно: вставка события и изменение заказа должны идти в одной транзакции. Иначе можно записать событие, упасть и больше никогда не обновить заказ.
Шаг 3. Быстрый ответ и очередь
Провайдер ждет ответа ограниченное время. Схема, которая работает под нагрузкой:
- Проверить подпись.
- Сохранить событие.
- Сразу вернуть успешный HTTP-ответ.
- Обработать событие в фоновой очереди с повторами.
Не отправляйте письма, не вызывайте CRM и не формируйте документы синхронно в обработчике — любая медленная операция вызовет таймаут и повторную отправку.
Шаг 4. События не по порядку
Событие «оплата подтверждена» может прийти раньше, чем «платеж создан», а «возврат» — раньше, чем вы обработали оплату. Решение — машина состояний заказа с явными переходами:
| Текущий статус | Допустимые переходы |
|---|---|
| pending | paid, failed, cancelled |
| paid | refunded, partially_refunded |
| failed | paid (повторная попытка) |
| refunded | — |
Если событие предлагает недопустимый переход (например, из refunded обратно в paid), не применяйте его, а запишите в лог для разбора. Дополнительно можно сравнивать время события провайдера с временем последнего изменения заказа.
Шаг 5. Сверка и самовосстановление
Вебхук может не прийти совсем: сбой сети, ошибка на вашей стороне, неверный URL после деплоя. Поэтому нужна сверка:
- периодическая задача находит заказы, которые долго висят в
pending; - для них запрашивается статус платежа через API провайдера;
- расхождения исправляются автоматически или попадают в очередь на ручную проверку.
Так вебхук становится быстрым путем обновления, а не единственным источником истины.
Частые ошибки
- Парсинг JSON до проверки подписи, из-за чего подпись не совпадает.
- Ответ ошибкой на дубль — провайдер будет повторять отправку снова и снова.
- Доверие сумме из вебхука без сверки с суммой заказа.
- Отсутствие логов с сырым телом запроса — потом невозможно разобрать инцидент.
- Тестирование только «счастливого пути» без дублей и обратного порядка.