API и надёжная интеграция

Идемпотентность платёжных запросов: защита от дублей

У создания и обработки результата разные ключи: order_id защищает бизнес-заказ, а payment_id или event key защищает выдачу от повторного webhook.

Идемпотентность в платежах — это гарантия, что повтор одного и того же запроса не превратится во второй платёж и не выдаст товар дважды. Достигается она двумя разными ключами: 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. Три попытки, не больше. Паузы 1, 2 и 4 секунды плюс случайные 0–300 мс, чтобы попытки разных пользователей не сошлись в одну секунду.
  2. Новый X-Nonce на каждую попытку. Иначе вторая вернёт 401 вместо ответа по существу.
  3. Тот же order_id. Это и делает повтор безопасным.
  4. Общий бюджет времени. Если суммарно прошло больше 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 млн ₽ в год. Категорию проекта и доступные способы оплаты подтверждает модерация — на это стоит закладывать время до запуска, если вы собираете платежи для стартапа.

Частые вопросы

Чем order_id отличается от X-Nonce?

order_id — стабильный идентификатор заказа, он живёт столько же, сколько заказ, и делает повтор создания безопасным. X-Nonce наоборот обязан быть новым на каждый HTTP-вызов, живёт 10 минут, а повтор возвращает 401 nonce already used. При ретрае меняйте nonce и оставляйте прежний order_id.

Почему проверка через SELECT перед INSERT не защищает от дублей?

Между чтением и записью проходит время, и два параллельных обработчика успевают увидеть пустую таблицу оба. В результате обе вставки проходят, и товар выдаётся дважды. Защиту даёт уникальный индекс или составной первичный ключ плюс ON CONFLICT DO NOTHING с RETURNING.

Что делать, если ответ на создание платежа не пришёл?

Повторите запрос с тем же order_id и новым X-Nonce: если платёж уже создан, вы получите его вместе с pay_url и expires_at, а второй платёж не появится. Для этого строку заказа надо записывать в базу до вызова API, а не после ответа. Тогда идентификатор известен даже при падении процесса посередине.

Какие ошибки повторять не нужно?

Ответы 400 вида amount must be positive или payment_currency must be RUB повторять бессмысленно: данные сами не изменятся. Повторяют таймауты, обрывы соединения и 500 unable to create payment. Три попытки с паузами 1, 2 и 4 секунды и случайной добавкой до 300 мс закрывают почти все сетевые сбои.

Как убедиться, что защита от дублей работает?

Прогоните на тестовом режиме четыре сценария: два создания с одним order_id, два одинаковых колбэка подряд, падение процесса между записью и выдачей, и два параллельных события одновременно. Правильное поведение — один платёж и одна выдача во всех четырёх случаях. Читать код для этого недостаточно, гонки видны только на реальных запросах.

Источники и документация