Идемпотентность в платежах — это гарантия, что повтор одного и того же запроса не превратится во второй платёж и не выдаст товар дважды. Достигается она двумя разными ключами: order_id защищает создание платежа, а отдельный ключ на стороне вашей базы защищает выдачу. Путать их нельзя — они закрывают разные дыры.
Сеть теряет ответы, покупатели жмут кнопку дважды, платёжная система повторяет колбэк, пока не получит 2xx. Всё это норма, а не авария. Ваша задача — сделать так, чтобы повтор ничего не менял.
Два ключа, две зоны ответственности
| Ключ | Что защищает | Кто его придумывает |
|---|---|---|
order_id | Создание платежа: один заказ — один платёж | Вы, при создании заказа в своей базе |
payment_id из события | Выдачу товара после оплаты | Платёжная система, приходит в колбэке |
idempotency_key | Выплаты: одна и та же выплата не уйдёт дважды | Вы, отдельным полем в запросе |
Дальше по тексту — как каждый из них работает и что происходит, если положиться только на первый.
Двойной клик: спасает order_id
Покупатель нажал «Оплатить», страница задумалась на секунду, он нажал ещё раз. Ваш бэкенд отправил два POST /api/v1/payments. Если order_id в них одинаковый, второй запрос вернёт тот же платёж, а не создаст новый: идемпотентность создания привязана именно к этому полю.
Ошибка, которая всё ломает, выглядит невинно: order_id: crypto.randomUUID() прямо в момент отправки. Тогда каждый клик — новый заказ и новая ссылка, а в отчёте у вас две «незавершённые» оплаты на одного человека. Идентификатор должен рождаться вместе со строкой в таблице orders и жить, пока живёт заказ.
Не путайте order_id с X-Nonce. Nonce наоборот обязан быть новым на каждый HTTP-вызов и живёт 10 минут: повтор вернёт 401 nonce already used. При ретрае берите новый nonce и прежний order_id.
Двойной колбэк: order_id уже не помогает
Событие payment.paid может прийти дважды по совершенно законным причинам: ваш сервер ответил медленнее таймаута, отвалилась сеть на середине ответа, отработал механизм повторной доставки. Отправитель не знает, дошло ли, и шлёт снова.
Здесь order_id бесполезен — оба события относятся к одному заказу, и оба выглядят валидно. Нужен ключ на само событие. Практичный вариант — пара payment_id + status: она уникальна для каждого перехода платежа и приходит в теле колбэка. Записали событие — выдали доступ. Не записалось из-за конфликта — значит, доступ уже выдан, и вы просто отвечаете 200.
Уникальный индекс вместо проверки «а есть ли уже»
Соблазнительно написать «сначала SELECT, если строки нет — INSERT». Это не защита, а её имитация. Между чтением и записью проходит миллисекунда, и если два колбэка обрабатываются параллельно, оба увидят пустоту и оба вставят строку. Настоящая защита живёт в базе.
CREATE TABLE payment_events (
payment_id text NOT NULL,
status text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (payment_id, status)
);
-- обработка колбэка
INSERT INTO payment_events (payment_id, status)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING payment_id;
Если RETURNING вернул строку — событие новое, запускайте выдачу. Если вернул пусто — это повтор, выдача уже была, отвечайте 200 и ничего не делайте. Вся гонка решается одним запросом, без блокировок и без проверок в коде приложения.
Тот же приём работает и на создании заказа: уникальный индекс по order_id в таблице orders не даст двойному клику по кнопке «Купить» породить два заказа ещё до обращения к платёжному API.
Ретраи: что повторять, а что бессмысленно
Повторять стоит не всё. Таймаут, обрыв соединения, 500 unable to create payment — повторяйте. 400 payment_currency must be RUB или 400 amount must be positive — не повторяйте никогда: данные не изменятся сами, вы просто нагрузите API и потратите время покупателя.
- Три попытки, не больше. Паузы 1, 2 и 4 секунды плюс случайные 0–300 мс, чтобы попытки разных пользователей не сошлись в одну секунду.
- Новый X-Nonce на каждую попытку. Иначе вторая вернёт 401 вместо ответа по существу.
- Тот же order_id. Это и делает повтор безопасным.
- Общий бюджет времени. Если суммарно прошло больше 15 секунд, покажите покупателю честную ошибку и предложите повторить — висящий спиннер хуже отказа.
Ответ потерялся, а платёж создался
Самая неприятная ситуация: вы отправили запрос, ответ не дошёл, и вы не знаете, есть платёж или нет. Разгадка в том, что знать и не нужно — достаточно повторить запрос с тем же order_id и получить в ответ уже существующий платёж вместе с его pay_url и expires_at.
Чтобы это работало, храните order_id до отправки, а не после ответа. Порядок такой: строка в orders со статусом pending → вызов API → сохранение payment_id и pay_url. Если процесс упал посередине, у вас остался заказ с известным идентификатором, и повтор безопасен.
Сервис бронирования студий так закрыл жалобы «списали дважды»: раньше при таймауте бэкенд создавал новый заказ, покупатель видел вторую ссылку и оплачивал обе. После перехода на стабильный order_id повторная попытка стала возвращать ту же самую ссылку, и вторая оплата стала невозможна.
Как проверить это до прода
Идемпотентность не проверяется чтением кода — только опытом. Четыре сценария, которые надо прогнать на тестовом режиме, прежде чем включать приём денег:
- Двойной вызов создания. Отправьте два запроса с одинаковым
order_idи разными nonce — в базе должен остаться один платёж. - Двойное событие. Отправьте один и тот же валидный колбэк дважды — выдача должна произойти один раз, оба ответа 200.
- Падение в середине. Уроните процесс между
INSERTи отправкой письма, поднимите, дайте прийти повтору — письмо должно уйти ровно одно. - Параллельные колбэки. Пошлите два одинаковых события одновременно; при уникальном индексе выдача сработает один раз, при проверке через
SELECT— две.
Подробнее про песочницу — в статье о тестовом режиме платежей, а про то, как отличить подлинное событие от подделки, — в разборе проверки подписи webhook. Общая картина подключения собрана в обзоре API приёма платежей.
Возвраты, чек и налоги остаются зоной продавца: платёжный сервис обеспечивает техническую часть, а не ваш статус. Самозанятый выдаёт чек в «Мой налог» и следит за лимитом 2,4 млн ₽ в год. Категорию проекта и доступные способы оплаты подтверждает модерация — на это стоит закладывать время до запуска, если вы собираете платежи для стартапа.