Берегите очко смолоду
Часть 7 — про деньги, вебхуки и места, где красивый код проигрывает скучной надёжности.

Почему это важно не только разработчику#

Платёжная ошибка почти сразу становится бизнес-проблемой: возвраты, ручные сверки, споры с пользователями, недоверие к балансу. Даже если баг технически маленький, последствия видны в деньгах.

Вебхуки нужно воспринимать как внешние сообщения, которым нельзя верить без проверки. Платёжный провайдер может прислать событие повторно, пользователь может обновить страницу, сеть может оборваться между двумя шагами. Система должна выдерживать такие повторы спокойно.

Самый неприятный баг я нашёл не в логах и не в отчёте багхантера. Его принёс пользователь: «у меня баланс пополнился дважды, но я платил один раз». Звучит как подарок судьбы для пользователя и как маленький инфаркт для того, кто этот баланс потом сводит с деньгами на счету провайдера.

Дальше — про то, почему деньги ломаются иначе, чем всё остальное, и почему вебхук — это не «уведомление», а полноценный протокол, к которому надо относиться серьёзно.

Повтор вебхука — это норма#

Первое, с чем стоит смириться: платёжный провайдер пришлёт вам один и тот же вебхук не один раз. Так задумано. Сеть моргнула, ваш сервер ответил с задержкой, балансировщик решил переотправить — и вот вы получаете «оплата прошла» дважды, трижды, иногда с интервалом в часы.

Если обработчик на это не рассчитан, каждая доставка делает свою работу честно и до конца.

У меня было ровно так. Логику пополнения баланса писали в два захода, двумя почти одинаковыми ветками. В одной ветке стояла проверка на повтор, в другой — нет. Глазами они выглядели как близнецы, и именно поэтому никто не заметил, что у одного из близнецов нет защиты.

async function applyTopup(event: WebhookEvent) {
  await db.balance.increment(event.userId, event.amount);
}

Повторная доставка — и баланс прибавился ещё раз. Лечится не хитростью, а ключом идемпотентности на каждой денежной операции: запомнили идентификатор события, на повтор отвечаем «уже обработано» и не трогаем баланс.

async function applyTopup(event: WebhookEvent) {
  const created = await db.processedEvents.tryInsert(event.id);
  if (!created) return;
  await db.balance.increment(event.userId, event.amount);
}

Правило простое: если операция двигает деньги, у неё должен быть ключ идемпотентности. Не «у важных», не «у большинства» — у каждой. И проверять надо не наличие защиты в одной ветке, а её отсутствие в соседней.

Идемпотентность не должна хоронить ретраи#

У ключа идемпотентности есть неприятный крайний случай: если вы вставили «событие получено» до обработки, а потом обработка упала, повторная доставка может навсегда заблокироваться. Провайдер ретраит, код видит знакомый eventId и отвечает «уже было». Только денег пользователю так и не начислили.

Плохая модель — один булевый факт «видел / не видел». Нормальная модель хранит состояние обработки: processing, processed, failed. Дедуп срабатывает только на processed. Ошибка переводит событие в failed и возвращает провайдеру не-2xx, чтобы он повторил доставку.

// плохо: insert-first без статуса превращает transient-сбой в вечную потерю события
await webhookEvents.insert({ id: event.id });
await grantSubscription(event);

// хорошо: processed — единственное состояние, которое блокирует повтор
const existing = await webhookEvents.find(event.id);
if (existing?.status === "processed") return;
await webhookEvents.upsert({ id: event.id, status: "processing" });
await grantSubscription(event);
await webhookEvents.update(event.id, { status: "processed" });

Идемпотентность нужна не для того, чтобы «меньше раз выполнять код». Она нужна, чтобы повтор был безопасным. Если она блокирует восстановление после сбоя, это уже не защита, а новый источник потери денег.

Подпись, которая не сходится по байтам#

Вебхуку нельзя верить просто потому, что он пришёл на нужный URL. Подделать POST-запрос может кто угодно. Поэтому провайдеры подписывают тело, а вы проверяете подпись.

И вот тут меня ждал отдельный тонкий момент. Подпись считается по сырому телу запроса, байт в байт. А байты у всех разные. Классический случай: провайдер на одном языке сериализует JSON со своими привычками — например, экранирует слэши в URL внутри строк, — а вы на своей стороне берёте уже распарсенный объект, заново его сериализуете «как принято у вас», и считаете подпись от этого. Результат не совпадает никогда, хотя данные те же самые.

Лечится двумя вещами. Первое: для проверки входящих вебхуков подпись считается строго по сырому телу запроса, до любого парсинга. Второе: подпись исходящих запросов и проверка входящих — это две разные задачи, и им нужны две разные функции. Соблазн сделать одну «универсальную» приводит ровно к тому, что вы подписываете и проверяете по чуть-чуть разным правилам.

const raw = await readRawBody(req);
const expected = hmac(secret, raw);
if (!timingSafeEqual(expected, req.headers["x-signature"])) {
  return res.status(400).end();
}
const event = JSON.parse(raw);

Сверяйтесь с документацией провайдера буквально: какой алгоритм, какое кодирование, что именно входит в подписываемую строку. Здесь «вроде так же» не работает.

Тихая двестка, которая теряет деньги#

Был ещё тонкий случай, который я долго не понимал. Платежи иногда просто пропадали. Провайдер уверен, что доставил, у нас — ничего.

Оказалось, в одной ветке обработчик при отсутствии сырого тела молча отвечал 200 OK без всякой обработки. Для провайдера 200 — это «принято, можно забыть». Он не ретраил. А мы не обработали. Платёж растворялся между двумя «всё хорошо».

Код ответа на вебхук — это часть протокола надёжности, а не формальность. Если вы не смогли обработать событие, скажите об этом честно: верните 4xx или 5xx, и провайдер повторит доставку. «Тихая двестка» там, где обработки не произошло, — это способ потерять данные молча.

if (!raw) return res.status(400).end();

Не верьте телу — перезапросите статус#

Про доверие к чужому вводу была отдельная часть серии, и вебхук — тот же самый ввод, только в дорогом костюме. Тело вебхука можно подделать, перехватить, переиграть. Поэтому перед тем как что-то начислять, статус платежа я перезапрашиваю у провайдера через его API по идентификатору и сверяю заново.

И сверяю не только «оплачено / не оплачено». Сумму и тариф считает сервер, а не клиент и не присланное тело. Активность тарифа проверяется отдельно: нельзя оплатить скрытый, архивный или выключенный тариф только потому, что кто-то подставил его идентификатор в запрос. Сторона, которая получает деньги, обязана сама знать, сколько и за что.

Сохранённая карта — это отдельное действие#

Ещё один баг всплыл позже, уже после публикации серии. Он не про поддельный вебхук и не про двойное начисление, а про границу согласия пользователя.

Пользователь один раз оплатил подписку картой. Провайдер сохранил платёжный метод для будущих списаний. После этого обычная кнопка «оплатить через провайдера» в боте могла не открыть новый счёт, а тихо попробовать списать с сохранённой карты. Технически всё выглядело логично: карта есть, значит можно ускорить оплату. На практике это плохой UX и плохая граница безопасности. Пользователь нажимает одну кнопку, а система делает другое действие с другой моделью риска.

// плохо: обычная кнопка оплаты неявно списывает с сохранённой карты
if (user.savedPaymentMethodId) {
  return chargeSavedCard(user.savedPaymentMethodId, tariffId);
}
return createNewInvoice(tariffId);

Особенно неприятно это ломается на отозванном разрешении. Провайдер возвращает permission_revoked, сохранённый метод надо чистить, рекуррентные списания — останавливать, а пользователю показывать понятный путь восстановления. Если вместо этого он видит общую ошибку после клика по обычной оплате, вы получаете не «мелкий UX-баг», а денежный сценарий без явного согласия и без нормального восстановления.

Фикс простой: сохранённая карта — отдельная кнопка и отдельный обработчик. Обычная кнопка всегда ведёт к новому счёту картой/СБП. Если сохранённый метод есть, сначала показываем выбор: «списать с сохранённой карты» или «оплатить новой картой / СБП». А неудачную попытку списания помечаем технической меткой и обрабатываем отдельно.

// хорошо: пользователь явно выбирает модель оплаты
if (user.savedPaymentMethodId) {
  return showPaymentChoice({
    savedCard: `pay_saved_card:${tariffId}`,
    newInvoice: `pay_new_card:${tariffId}`,
  });
}
return createNewInvoice(tariffId);

Платёжный токен — это право на списание. Даже если провайдер разрешает списать по нему повторно, продуктовая кнопка не должна менять смысл без явного выбора пользователя.

Два успешных платежа одновременно#

Ещё один денежный баг не связан с вебхуками напрямую. Два платежа могут успешно прийти почти одновременно: пользователь открыл две вкладки, провайдер ретраит, автопродление совпало с ручной оплатой. Если продление подписки устроено как «прочитал текущую дату → прибавил месяц → записал», второй процесс может перезаписать результат первого.

// плохо: read-modify-write без лока теряет одно продление
const user = await db.users.find(userId);
const next = addMonth(max(user.subscriptionUntil, now));
await db.users.update(userId, { subscriptionUntil: next });

Денежный side-effect должен быть сериализован: SELECT ... FOR UPDATE, ledger-таблица с уникальным ключом операции или атомарный SQL-апдейт. Не «проверили, что платёж уникальный», а именно защитили состояние, которое меняется из-за платежа.

Деньги — это место, где обычные инженерные приоритеты переворачиваются. В большинстве кода можно простить лишний запрос, неидеальную структуру, повтор логики. Здесь — нельзя.

Здесь важнее всего три скучные вещи. Идемпотентность: повтор не должен делать работу дважды. Ретраи: они будут, и система обязана это пережить. Подлинность: side-effect случается только после того, как вы сами, у первоисточника, убедились, что событие настоящее и относится к нужной сумме.

Красивый код, который списывает дважды, хуже некрасивого, который списывает один раз. На деньгах надёжность побеждает эстетику — и это, пожалуй, единственное место, где я не спорю.


Если у проекта есть платежи, подписки, вебхуки или любые операции с деньгами, их нужно проверять до жалоб пользователей и ручных возвратов. Напишите мне в Telegram или на почту — помогу разобрать идемпотентность, ретраи, сверку с провайдером и места, где один повтор может стоить денег.