Короткий ответ
Идемпотентность webhook обеспечивают по уникальному event_id, который присылает провайдер, сохраняя его в БД с уникальным ограничением и обрабатывая платёж только при первой встрече этого event_id, так как провайдеры почти всегда гарантируют лишь at-least-once доставку.
Как это работает подробнее
При получении webhook сервис атомарно пытается вставить event_id в таблицу обработанных событий; если вставка конфликтует (уже есть) — просто возвращает 200 OK без повторного применения эффекта.
Пример кода
example.py
def handle_webhook(payload):
with db.transaction():
inserted = db.execute(
"INSERT INTO webhook_events (event_id, payment_id, event_type) VALUES (%s,%s,%s) ON CONFLICT DO NOTHING RETURNING event_id",
[payload["event_id"], payload["payment_id"], payload["type"]],
)
if not inserted:
return # дубликат
apply_payment_event(payload)Что использовать на практике
- Практика. Важно отличать event_id конкретного уведомления от бизнес-идентификатора платежа — провайдер может присылать несколько разных событий по одному платежу (created, succeeded, refunded), и каждое обрабатывается идемпотентно по своему event_id.
Подводные камни
- Риск. Проверять дубликаты только по payment_id, из-за чего второе легитимное событие по тому же платежу ошибочно отфильтруется как дубль; обрабатывать webhook без транзакционной атомарности между «отметить обработанное» и «применить эффект».