Идемпотентность на практике: Idempotency-Key и webhooks

Как сделать так, чтобы повторный POST не списал деньги дважды, и как надёжно доставлять события клиентам через webhooks.

Idempotency-Key — заголовок с уникальным ключом, который клиент генерирует для запроса, чтобы сервер при повторе вернул тот же результат, а не выполнил операцию ещё раз.

Проблема двойного POST

Методы GET, PUT и DELETE идемпотентны по своей природе: повтори их хоть десять раз — состояние сервера не изменится сверх первого вызова. А вот POST — нет. Каждый POST /payments создаёт новый платёж. И ровно здесь начинается боль: пользователь нажал «Оплатить», запрос ушёл, но ответ потерялся из-за обрыва сети или таймаута. Клиент не знает, прошёл платёж или нет, и повторяет запрос. Если сервер наивен — он спишет деньги дважды.

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

Заголовок Idempotency-Key

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

curl -X POST https://api.example.com/payments \
  -H "Authorization: Bearer TOKEN" \
  -H "Idempotency-Key: 7d3a1f02-9c4e-4b21-8f6a-2c1d0e5b9a44" \
  -H "Content-Type: application/json" \
  -d '{"amount": 1990, "currency": "RUB", "order_id": 42}'

Первый запрос создаёт платёж и возвращает 201 Created. Если тот же запрос с тем же ключом придёт ещё раз — сервер вернёт тот же ответ (часто с тем же телом и кодом), но второго списания не будет. Ключ генерирует именно клиент: только он знает, что «это повтор того же намерения», а не новая оплата.

Как работает под капотом

Сервер заводит хранилище соответствий «ключ → результат» (обычно в Redis или таблице БД с TTL на сутки-двое). Логика обработки выглядит так:

  приходит POST с Idempotency-Key = K
        │
        ▼
  есть ли запись по ключу K?
     ├─ нет  ─> атомарно «застолбить» K (статус: в обработке)
     │         выполнить операцию (списать деньги)
     │         сохранить ответ под ключом K
     │         вернуть ответ, код 201
     │
     └─ да   ─> запрос ещё в обработке -> вернуть 409 / попросить повторить позже
               запрос завершён       -> вернуть СОХРАНЁННЫЙ ответ (без повторного списания)

Тонкость — гонка: два одинаковых запроса могут прийти почти одновременно. Поэтому ключ «застолбляют» атомарно (например, INSERT с уникальным индексом по ключу или SET key value NX в Redis). Тот, кто проиграл гонку, получает ответ «уже в обработке» и повторяет позже. Многие платёжные API дополнительно хранят хеш тела запроса: если по тому же ключу пришло другое тело, это ошибка клиента, и сервер отвечает 422 — ключ должен соответствовать ровно одному запросу.

Webhooks: сервер вызывает клиента

Webhook — обратный вызов: сервер сам шлёт HTTP-запрос на заранее заданный клиентом URL, когда происходит событие, вместо того чтобы клиент постоянно опрашивал API.

Обычный REST устроен так: клиент спрашивает — сервер отвечает. Но как клиенту узнать, что платёж наконец подтвердился банком через 30 секунд? Опрашивать GET /payments/42 в цикле — расточительно. Webhooks переворачивают направление: клиент один раз регистрирует свой URL (подписка), и когда событие случается, сервер делает POST на этот URL с описанием события.

{
  "id": "evt_8a1b",
  "type": "payment.succeeded",
  "created": 1718900000,
  "data": {
    "payment_id": 42,
    "amount": 1990,
    "currency": "RUB",
    "status": "succeeded"
  }
}

Поток событий выглядит так:

  ваш сервис                     платёжный провайдер
      │  подписка: POST /webhooks       │
      │  { url, events }  ───────────-> │  сохранил подписку
      │                                 │
      │           ... позже ...         │
      │                                 │  событие payment.succeeded
      │  <───────── POST /my/webhook ── │  доставка события
      │  200 OK  ───────────────────->  │  отметил доставленным

Надёжность доставки

Сеть ненадёжна, ваш сервер может на секунду упасть — а событие терять нельзя. Поэтому у зрелых webhook-систем есть несколько механизмов:

  • Ретраи. Если получатель ответил не 2xx или не ответил вовсе — провайдер повторяет доставку с нарастающей паузой (экспоненциальный backoff): через секунду, минуту, час, и так до нескольких суток.
  • Подпись HMAC. Чтобы получатель убедился, что запрос пришёл от настоящего провайдера, тело подписывается секретом. В заголовок кладут подпись, например X-Signature: sha256=.... Получатель сам считает HMAC-SHA256 от тела со своим секретом и сравнивает — совпало, значит, источник подлинный и тело не подменили.
  • Идемпотентность на стороне получателя. Из-за ретраев одно событие может прийти дважды. Получатель обязан быть готов: запоминать id события (evt_8a1b) и при повторе просто отвечать 200 OK, не выполняя обработку второй раз.
РискМеханизм защиты
Событие не дошло (сеть, падение)ретраи с backoff
Подделка запросаHMAC-подпись тела
Дубль доставкиидемпотентность по event id

Частые ошибки

Сервер генерирует Idempotency-Key сам. Тогда смысл теряется: при ретрае нужен тот же ключ, а его знает только клиент. Ключ всегда формирует и хранит отправитель.

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

Не проверяют подпись. Открытый webhook-эндпоинт без HMAC — это дыра: кто угодно может прислать «payment.succeeded» и выдать заказ бесплатно.

Получатель не идемпотентен. Без проверки id события повторная доставка приведёт к двойной отгрузке или двойному начислению.

Итоги

  • POST не идемпотентен; Idempotency-Key от клиента превращает повтор в безопасную операцию.
  • Сервер дедуплицирует через хранилище «ключ → результат» и атомарное «застолбление» ключа.
  • Webhooks — обратные вызовы: сервер шлёт событие на URL клиента вместо опроса.
  • Надёжность webhooks держится на трёх китах: ретраи, HMAC-подпись и идемпотентность получателя по id события.
Проверьте себя
1. Кто должен генерировать значение заголовка Idempotency-Key?
AСервер при получении запроса
BКлиент перед отправкой, и при ретрае использует тот же ключ
CБалансировщик нагрузки
DБаза данных автоинкрементом
2. Что произойдёт при повторном POST с уже виденным сервером Idempotency-Key (запрос завершён)?
AОперация выполнится ещё раз
BСервер вернёт сохранённый результат первого вызова без повторного выполнения
CСервер всегда вернёт 500
DКлюч будет проигнорирован
3. Зачем webhook-провайдеры подписывают тело запроса через HMAC?
AЧтобы сжать тело и ускорить доставку
BЧтобы получатель убедился в подлинности источника и целостности тела
CЧтобы зашифровать данные от провайдера к получателю
DЧтобы автоматически повторять доставку
4. Почему получатель webhook обязан быть идемпотентным?
AПотому что HTTP запрещает повторные запросы
BИз-за ретраев одно событие может прийти несколько раз, и обработать его повторно нельзя
CЧтобы ускорить ответ сервера
DЧтобы не проверять подпись