Идемпотентность на практике: 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 события.