Чеклист идемпотентных платежных вебхуков для безопасных обновлений биллинга
Чеклист по идемпотентным платежным вебхукам: дедупликация событий, обработка повторов и безопасные обновления invoices, subscriptions и entitlements.

Почему платежные вебхуки создают дублирующие обновления
Платежный вебхук — это сообщение от платёжного провайдера на ваш бэкенд о важном событии: платёж прошёл, счёт оплачен, подписка обновлена или сделан возврат. По сути провайдер говорит: «Вот что произошло. Обновите свои записи.»
Дубликаты происходят потому, что доставка вебхуков устроена надёжно, а не единожды. Если ваш сервер медленный, таймаутит, возвращает ошибку или недоступен, провайдер обычно повторит отправку того же события. Также можно получить два разных события, относящихся к одному и тому же реальному действию (например, событие invoice и событие payment, связанные с одним платёжом). События могут прийти не по порядку, особенно если идут быстрые последующие действия вроде возврата.
Если обработчик не идемпотентен, одно и то же событие может примениться дважды, что сразу заметят клиенты и бухгалтерия:
- Счёт помечен как оплаченный дважды — дубли бухгалтерских записей
- Продление применено дважды — доступ продлён слишком далеко
- Права (entitlements) выданы дважды (лишние кредиты, места, функции)
- Возвраты или chargeback не корректно отменяют доступ
Это не просто «лучшие практики». Это разница между надёжной системой биллинга и системой, которая генерирует заявки в поддержку.
Цель этого чеклиста проста: обрабатывать каждое входящее событие как «применить не более одного раза». Вы будете сохранять стабильный идентификатор для каждого события, безопасно обрабатывать повторы и контролируемо обновлять invoices, subscriptions и entitlements. Даже если вы собираете бэкенд в no‑code инструменте вроде AppMaster, те же правила актуальны: нужна простая модель данных и повторяемый поток обработки, устойчивый к повторам.
Основы идемпотентности, которые можно применить к вебхукам
Идемпотентность означает, что повторная обработка одного и того же входа приводит к тому же конечному состоянию. В терминах биллинга: счёт оплачивается один раз, подписка обновляется один раз, доступ предоставляется один раз, даже если вебхук доставлен дважды.
Провайдеры повторяют отправку, когда ваш endpoint таймаутит, возвращает 5xx или сеть прерывается. Эти повторы — те же самые события. Это отличается от нового события, представляющего реальное изменение, например возврата через несколько дней. Новые события имеют другие ID.
Чтобы всё работало, нужны две вещи: стабильные идентификаторы и небольшая «память» о том, что вы уже видели.
Какие ID важны (и что сохранять)
Большинство платёжных платформ включают event ID, уникальный для вебхука. Некоторые также дают request ID, idempotency key или уникальный идентификатор платёжного объекта (как charge или payment intent) внутри полезной нагрузки.
Сохраняйте то, что помогает ответить на вопрос: «Я уже применял именно это событие?»
Практически минимальный набор:
- Event ID (уникальный ключ)
- Тип события (полезно для отладки)
- Временная метка получения
- Статус обработки (processed/failed)
- Ссылка на затронутого клиента, счёт или подписку
Ключевой приём — записать event ID в таблицу с уникальным ограничением. Тогда обработчик может безопасно: сначала вставить event ID; если он уже существует, остановиться и вернуть 200.
Как долго хранить записи для дедупа
Храните записи достаточно долго, чтобы покрыть поздние повторы и расследования. Обычное окно — 30–90 дней. Если вы работаете с chargeback, спорами или длинными циклами подписки, храните дольше (6–12 месяцев) и периодически чистите старые строки, чтобы таблица оставалась быстрой.
В сгенерированном бэкенде вроде AppMaster это легко соответствует простой модели WebhookEvents с уникальным полем на event ID и бизнес‑процессом, который раннее выходит при обнаружении дубликата.
Спроектируйте простую модель данных для дедупа событий
Хороший обработчик вебхуков — в основном задача с данными. Если вы можете зафиксировать каждое событие провайдера ровно один раз, всё остальное становится безопаснее.
Начните с одной таблицы, которая действует как журнал квитанций. В PostgreSQL (включая моделирование в AppMaster Data Designer) держите её маленькой и строгой, чтобы дубликаты падали сразу.
Минимум, который нужен
Практический базис для таблицы webhook_events:
provider(text, например "stripe")provider_event_id(text, обязательное)status(text, например "received", "processed", "failed")processed_at(timestamp, nullable)raw_payload(jsonb или text)
Добавьте уникальное ограничение на (provider, provider_event_id). Это правило — ваш главный щит от дублей.
Также вам понадобятся бизнес‑ID для поиска записей, которые вы будете обновлять. Они отличаются от event ID.
Типичные примеры: customer_id, invoice_id, subscription_id. Храните их как текст — провайдеры часто используют нечисловые ID.
Raw payload vs распарсенные поля
Сохраняйте raw payload, чтобы можно было отладить и повторно обработать позже. Распарсенные поля упрощают запросы и отчётность, но сохраняйте только то, что реально используете.
Простой подход:
- Всегда храните
raw_payload - Также сохраняйте несколько распарсенных ID, которые часто запрашиваете (customer, invoice, subscription)
- Храните нормализованный
event_type(text) для фильтрации
Если событие invoice.paid приходит дважды, уникальное ограничение блокирует вторую вставку. У вас остаётся raw payload для аудита, а распарсенный invoice ID упрощает поиск записи счёта, которую вы обновили в первый раз.
Пошагово: безопасный поток обработчика вебхуков
Безопасный обработчик преднамеренно скучный. Он ведёт себя одинаково каждый раз, даже если провайдер повторно отправляет одно и то же событие или доставляет их не по порядку.
5 шагов, которые нужно выполнять всегда
-
Проверьте подпись и распарсьте полезную нагрузку. Отклоняйте запросы с неверной подписью, неожиданным типом события или которые не парсятся.
-
Запишите событие до того, как тронете биллинговые данные. Сохраните provider event ID, тип, время создания и raw payload (или хеш). Если event ID уже существует, считайте его дубликатом и остановитесь.
-
Сопоставьте событие с одним «владельцем» записи. Решите, что вы обновляете: invoice, subscription или customer. Храните внешние ID в ваших записях, чтобы их можно было искать напрямую.
-
Примените безопасное изменение состояния. Двигайте состояние только вперёд. Не откатывайте оплаченный счёт из‑за позднего
invoice.updated. Записывайте, что вы применили (старое состояние, новое, временная метка, event ID) для аудита. -
Отвечайте быстро и логируйте результат. Возвращайте успех, как только событие безопасно сохранено и либо обработано, либо проигнорировано. Записывайте, было ли оно обработано, дедуплено или отклонено, и почему.
В AppMaster это обычно превращается в таблицу в базе для webhook events плюс Business Process, который проверяет «видели ли event ID?» и затем выполняет минимальные шаги обновления.
Обработка повторов, таймаутов и недопорядка доставки
Провайдеры повторяют вебхуки, если не получают быстрый успешный ответ. Они также могут отправлять события не по порядку. Ваш обработчик должен оставаться безопасным, когда одно и то же обновление приходит дважды, или когда более позднее обновление приходит раньше.
Практическое правило: отвечайте быстро, делайте работу позже. Рассматривайте запрос вебхука как квитанцию, а не место для тяжёлой логики. Если в запросе вы вызываете сторонние API, генерируете PDF или пересчитываете счета — вы повышаете вероятность таймаутов и дополнительных повторов.
Недопорядок: сохраняйте последнее истинное состояние
Доставка не по порядку — обычное явление. Прежде чем применить изменение, используйте две проверки:
- Сравнивайте временные метки: применяйте событие только если оно новее того, что уже записано для этого объекта (invoice, subscription, entitlement).
- Используйте приоритет статусов, когда временные метки близки или неясны: paid важнее open, canceled важнее active, refunded важнее paid.
Если вы уже пометили счёт как paid, а позже приходит «open» — игнорируйте его. Если пришёл «canceled», а позже появляется старое «active» — оставьте canceled.
Игнорировать или ставить в очередь
Игнорируйте событие, если можно доказать, что оно устарело или уже применено (тот же event ID, более старая временная метка, более низкий приоритет статуса). Ставьте в очередь событие, когда оно зависит от данных, которых у вас ещё нет, например обновление подписки пришло до создания записи клиента.
Практический паттерн:
- Сохраняйте событие немедленно со статусом обработки (received, processing, done, failed)
- Если зависимостей нет, помечайте как waiting и повторяйте обработку в фоне
- Задайте лимит повторов и тревогу при множественных ошибках
В AppMaster это хорошее соответствие: таблица webhook events плюс Business Process, который быстро подтверждает запрос и асинхронно обрабатывает отложенные события.
Безопасное обновление invoices, subscriptions и entitlements
После дедупации следующий риск — рассинхроны состояния: счёт показывает paid, а подписка всё ещё past due, или доступ выдан дважды и не отозван. Обрабатывайте каждый вебхук как переход состояния и применяйте его в одной атомарной операции.
Счета: делайте изменения статуса монотонными
Счета переходят через состояния: paid, voided, refunded. Бывают частичные платежи. Не меняйте статус счёта просто по последнему пришедшему событию. Храните текущее состояние и ключевые суммы (amount_paid, amount_refunded) и разрешайте только безопасные переходы вперёд.
Практические правила:
- Пометьте счёт как paid только один раз — при первом
paidсобытии. - Для возвратов увеличивайте
amount_refundedдо суммы счёта; никогда не уменьшайте её. - Если счёт voided, остановите отгрузку/выполнение, но сохраните запись для аудита.
- При частичных платежах обновляйте суммы, но не давайте преимущества «fully paid».
Подписки и entitlements: выдать один раз, отозвать один раз
Подписки включают продления, отмены и льготные периоды. Держите статус подписки и границы периода (current_period_start/end), а затем выводите окна доступа из этих данных. Entitlements лучше хранить как явные записи, а не одно булево поле.
Для контроля доступа:
- Одно предоставление entitlement на пользователя/продукт/период
- Одна запись об отзыве при окончании доступа (отмена, возврат, chargeback)
- Аудит‑трейл, указывающий, какое событие вебхука вызвало каждое изменение
Используйте одну транзакцию, чтобы избежать рассинхронов
Применяйте обновления invoices, subscriptions и entitlements в одной транзакции БД. Прочитайте текущие строки, проверьте, не было ли уже применено это событие, затем запишите все изменения вместе. Если что‑то падает — откатывайте, чтобы не получить «счёт оплачен» без «доступа» или наоборот.
В AppMaster это часто выражается одним Business Process, который обновляет PostgreSQL в контролируемом пути и пишет запись аудита рядом с бизнес‑изменением.
Проверки безопасности и сохранности данных для endpoint'ов вебхуков
Безопасность вебхуков — часть корректности. Если злоумышленник может попасть на ваш endpoint, он может попытаться создать поддельные «paid» состояния. Даже при дедупации нужно убедиться, что событие настоящее и что данные клиентов в безопасности.
Подтверждайте отправителя перед изменением биллинга
Валидируйте подпись каждого запроса. Для Stripe обычно проверяют заголовок Stripe-Signature, используя raw тело запроса (не переписанный JSON), и отклоняют события со старой временной меткой. Отсутствие заголовков рассматривайте как жёсткую ошибку.
Ранние базовые валидации: правильный HTTP метод, Content-Type и обязательные поля (event id, type и id объекта, по которому вы будете искать invoice или subscription). Если вы строите это в AppMaster, храните signing secret в переменных окружения или в безопасной конфигурации, но не в таблицах или клиентском коде.
Короткий чеклист безопасности:
- Отклоняйте запросы без валидной подписи и свежей временной метки
- Требуйте ожидаемые заголовки и content type
- Используйте доступ к БД с наименьшими привилегиями для обработчика вебхуков
- Храните секреты вне таблиц (env/config), ротируйте при необходимости
- Возвращайте 2xx только после безопасного сохранения события
Логи полезны, но не сливайте секреты
Логируйте достаточно для отладки повторов и споров, но избегайте чувствительных значений. Храните безопасную подсет PII: provider customer ID, внутренний user ID и, при необходимости, замаскированный email (например, a***@domain.com). Никогда не храните полные данные карты, полные адреса или raw authorization headers.
Логируйте то, что поможет воспроизвести ситуацию:
- Provider event id, type, created time
- Результат проверки (signature ok/failed) без хранения самой подписи
- Решение по дедупации (новое vs уже обработано)
- Внутренние ID, которые были затронуты (invoice/subscription/entitlement)
- Причина ошибки и счётчик попыток (если вы ставите в очередь)
Добавьте базовую защиту от злоупотреблений: rate limit по IP и (когда можно) по customer ID, и подумайте о разрешениях только для известных диапазонов IP провайдера, если инфраструктура это поддерживает.
Частые ошибки, которые приводят к двойным платежам или двойному доступу
Большинство ошибок в биллинге — не про математику, а про то, что вебхук рассматривают как единичное надёжное сообщение.
Ошибки, которые чаще всего приводят к дублирующим обновлениям:
- Дедуп по временной метке или сумме вместо event ID. Разные события могут иметь одинаковую сумму, и повторы могут прийти позже. Используйте уникальный event ID провайдера.
- Обновление БД до проверки подписи. Сначала проверяйте подпись, затем парсьте и действуйте.
- Считать каждое событие источником истины без проверки текущего состояния. Не помечайте счёт как paid, если он уже оплачен, возвращён или voided.
- Создание множества entitlement для одной покупки. Повторы могут создавать дубли. Предпочитайте upsert: «обеспечить наличие entitlement для subscription_id», затем обновляйте даты/лимиты.
- Провал вебхука из‑за недоступности сервиса уведомлений. Email, SMS, Slack и т. п. не должны блокировать биллинг. Ставьте уведомления в очередь и возвращайте успех после того, как основные изменения сохранены.
Простой пример: приходит событие продления дважды. Первая доставка создаёт строку entitlement. Повтор создаёт вторую строку, и приложение выдаёт «два активных entitlements» — дополнительные места или кредиты.
В AppMaster решение в основном про поток: сначала проверка, вставка записи события с уникальным ограничением, затем применение биллинговых обновлений с проверками состояния, а побочные эффекты (письма, квитанции) выполняйте асинхронно.
Реалистичный пример: дублированное продление + поздний возврат
Этот сценарий пугает, но им можно управлять, если обработчик безопасно можно перезапускать.
Клиент на месячном плане. Stripe отправляет событие продления (например, invoice.paid). Ваш сервер получает его, обновляет базу, но слишком долго отвечает (cold start, загруженная БД). Stripe думает, что доставка не удалась, и повторяет событие.
При первой доставке вы даёте доступ. При повторе вы обнаруживаете тот же event и ничего не делаете. Позже приходит событие возврата (например, charge.refunded) — вы отзываете доступ один раз.
Вот простой пример моделирования состояния в БД (таблицы, которые можно создать в AppMaster Data Designer):
webhook_events(event_id UNIQUE, type, processed_at, status)invoices(invoice_id UNIQUE, subscription_id, status, paid_at, refunded_at)entitlements(customer_id, product, active, valid_until, source_invoice_id)
Как должна выглядеть БД после каждого события
После События A (продление, первая доставка): в webhook_events появляется новая строка для event_id=evt_123 со status=processed. invoices помечается как paid. entitlements.active=true и valid_until сдвигается на следующий период.
После События A снова (повтор): вставка в webhook_events падает (уникальный event_id) или обработчик видит, что оно уже обработано. Никаких изменений invoices/entitlements не происходит.
После События B (возврат): новая строка в webhook_events для event_id=evt_456. В invoices.refunded_at ставится время и status=refunded. entitlements.active=false (или valid_until выставлен на now) с использованием source_invoice_id для отзыва доступа один раз.
Важно: проверка на дубли выполняется до любых записей grant/revoke.
Быстрая проверка перед запуском в прод
Прежде чем включить живые вебхуки, убедитесь, что одно реальное событие обновляет биллинг ровно один раз, даже если провайдер отправляет его дважды (или десять раз).
Используйте этот чеклист для end‑to‑end валидации:
- Подтвердите, что каждое входящее событие сначала сохраняется (raw payload, event id, type, created time и результат проверки подписи), даже если последующие шаги упадут.
- Убедитесь, что дубликаты обнаруживаются рано (тот же provider event id) и обработчик выходит без изменения invoices, subscriptions или entitlements.
- Докажите, что бизнес‑обновление одноразовое: одно изменение статуса счёта, одно изменение состояния подписки, одно предоставление/отзыв entitlement.
- Убедитесь, что ошибки записываются с достаточной детализацией для безопасного повтора (сообщение об ошибке, шаг, который упал, статус повтора).
- Проверьте, что обработчик отвечает быстро: подтверждайте приём после сохранения и избегайте тяжёлой работы внутри запроса.
Не нужен большой стек наблюдаемости, чтобы начать, но нужны сигналы. Отслеживайте через логи или простые дашборды:
- Всплески дублирующих доставок (часто нормально, но резкие скачки могут сигналить о таймаутах или проблемах провайдера)
- Высокий процент ошибок по типу события (например, много failed invoice payment)
- Растущее число событий в бэклоге ожидания повтора
- Несоответствия (оплачен счёт, но отсутствует entitlement; подписка отозвана, но доступ всё ещё активен)
- Резкий рост времени обработки
Если вы строите это в AppMaster, храните события в отдельной таблице в Data Designer и сделайте «mark processed» единой атомарной точкой решения в вашем Business Process.
Следующие шаги: тестируйте, мониторьте и реализуйте в no‑code бэкенде
Тестирование — место, где идемпотентность доказывает себя. Не ограничивайтесь счастливым путём. Повторяйте одно и то же событие несколько раз, отправляйте события не в порядке и форсируйте таймауты, чтобы провайдер сделал повторы. Вторая, третья и десятая доставка не должны ничего менять.
Планируйте возможность повторной обработки исторических событий. Рано или поздно вам захочется перепроиграть старые события после фикса бага, изменения схемы или инцидента провайдера. Если обработчик действительно идемпотентен, бэктреки становятся «прогоном событий через тот же pipeline» без создания дублей.
Служба поддержки должна иметь простой ранбук, чтобы вопросы не превращались в догадки:
- Найти event ID и проверить, помечен ли он как processed.
- Проверить запись invoice или subscription и подтвердить ожидаемое состояние и временные метки.
- Просмотреть запись entitlement (какой доступ выдан, когда и почему).
- При необходимости безопасно повторно запустить обработку для одного event ID.
- Если данные несогласованы — применить одно корректирующее действие и зафиксировать его.
Если вы хотите реализовать это без большого шаблонного кода, AppMaster (appmaster.io) позволяет моделировать основные таблицы и создавать поток вебхука визуально в Business Process, при этом генерируя реальный исходный код для бэкенда.
Попробуйте собрать обработчик вебхуков end‑to‑end в no‑code сгенерированном бэкенде и убедитесь, что он сохраняет корректность при повторах перед увеличением трафика и дохода.
Вопросы и ответы
Дублирующие доставки вебхуков — нормальное поведение: провайдеры стремятся к «как минимум одна доставка». Если ваш endpoint таймаутит, возвращает 5xx или соединение прерывается, провайдер будет повторно отправлять то же событие, пока не получит успешный ответ.
Используйте уникальный event ID от провайдера (идентификатор события вебхука), а не сумму счёта, временную метку или email клиента. Сохраните этот event ID с уникальным ограничением — тогда повторная доставка будет сразу обнаружена и безопасно проигнорирована.
Сначала вставьте запись о событии, прежде чем обновлять invoices, subscriptions или entitlements. Если вставка упадёт из‑за уже существующего event ID, прекратите обработку и верните успех, чтобы повторы не создали двойные обновления.
Храните записи дедупа достаточно долго, чтобы покрыть поздние повторы и расследования. Практический дефолт — 30–90 дней; храните дольше (например, 6–12 месяцев) при возвратах, спорах или длительных циклах подписки, а более старые строки периодически очищайте для быстроты запросов.
Проверяйте подпись перед тем, как менять биллинговые данные, а затем парсьте и валидируйте обязательные поля. Если подпись неверна, отклоняйте запрос и не вносите изменения — дедупация не защитит от поддельных «paid» событий.
Подтверждайте приём быстро после безопасного сохранения события и переносите тяжёлую работу в фон. Медленные обработчики увеличивают таймауты, что провоцирует повторы и повышает риск дублирующих обновлений, если что‑то не полностью идемпотентно.
Применяйте только те изменения, которые двигают состояние вперёд, и игнорируйте устаревшие события. Используйте временные метки событий и простую приоритетность статусов (например, refunded не должен быть перезаписан paid, canceled не должен быть перезаписан active).
Не создавайте новую запись доступа при каждом событии. Используйте правило вроде upsert — «обеспечить одну запись entitlement на пользователя/продукт/период (или на подписку)», затем обновляйте даты/лимиты и записывайте, какой event ID вызвал изменение для аудита.
Записывайте изменения invoices, subscriptions и entitlements в одной транзакции, чтобы они прошли вместе или не прошли вовсе. Это предотвращает рассинхроны вроде «счёт оплачен, но доступ не предоставлен» или «доступ отозван, но возврата нет».
Да. Создайте модель WebhookEvents с уникальным event ID и Business Process, который проверяет «уже видели?» и завершает обработку раньше при дубликате. Моделируйте invoices/subscriptions/entitlements в Data Designer, чтобы повторы и прогон исторических событий не создавали дублирующих строк.


