Истории про двойные списания всегда заканчиваются одинаково: возвратами, потерей доверия и многочасовым разбирательством с поддержкой. Идемпотентность — это техника, которая делает такие истории невозможными. В Pay Bot она встроена в API на уровне дизайна — но воспользоваться ей нужно правильно.
Что такое идемпотентность
Операция называется идемпотентной, если повторное её выполнение даёт тот же результат, что и первое. В контексте платежей это значит: ваш сервер может повторить запрос десять раз, а платёж создастся только один.
GET идемпотентен по своей природе — он только читает. DELETE тоже (удалили один раз, второй раз ничего не происходит). Самые «опасные» — POST и PATCH, потому что они создают/изменяют состояние. Идемпотентность для них нужно добавлять сознательно.
Почему без неё нельзя
Представьте классический сценарий:
- Ваш бэкенд отправляет
POST /v1/qr/createс суммой 50 000 ₸. - Pay Bot создаёт операцию и пишет в БД.
- Pay Bot пишет ответ в TCP-сокет — но клиентская сеть рвётся.
- Ваш HTTP-клиент бросает
ConnectionResetError. - Ваш код «не получил ответа» и… повторяет запрос.
Без идемпотентности на стороне Pay Bot — это новая операция. У вас два QR-кода с одинаковой суммой, и оба «правильные». Один клиент видит на сайте, второй уходит куда-то в логи. Если клиент по нему оплатит — деньги ушли вам, но вы про этот платёж не знаете.
Если повторений много — например, retry-цикл вашего job-runner'а — ущерб масштабируется.
Как это работает в Pay Bot
Каждый POST принимает заголовок Idempotency-Key. Это любая уникальная строка, которую вы придумываете.
Pay Bot хранит соответствие Idempotency-Key → результат в Redis (горячий слой) + PostgreSQL (долговременно). При повторном запросе с тем же ключом сервер возвращает тот же ответ, что отдал в первый раз — байт в байт, включая статус, заголовки и тело.
Срок жизни ключа — 24 часа. Этого хватает для любых разумных ретраев.
Как выбирать ключи
Ключ должен быть уникальным в пределах одной семантической операции. Несколько проверенных стратегий:
Привязка к бизнес-сущности
# Платёж за заказ — ключ из ID заказа
idempotency_key = f"order:{order.id}:payment"
Один заказ — один платёж. Сколько бы раз ваш код ни ретраил, новых платежей не создастся.
UUID v4 на создание операции
import uuid
key = str(uuid.uuid4())
# Сохраняем в БД вместе с состоянием "пытаемся создать платёж"
order.payment_attempt_key = key
order.save()
Подходит, если у операции пока нет естественного ID. Главное — сохранить ключ в БД до отправки запроса. Иначе после рестарта вы потеряете ключ и сделаете новый запрос с другим.
Хэш от входных параметров
import hashlib, json
payload = {"amount": 5000, "user_id": 42, "ts": "2026-04-12T10:00:00Z"}
key = "pay:" + hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
Удобно, когда операция не привязана к одной сущности, но детерминированно описывается параметрами. Минус: если параметры случайно совпадут у двух запросов — Pay Bot вернёт один и тот же ответ. В большинстве случаев это и нужно, но проверьте.
Чего не делать
- Время:
key = str(time.time()). При параллельном выполнении задач ключи могут совпасть, и вы потеряете запрос. - Случайные числа на стороне ретраер'а:
key = random()внутри функции, которая ретраится. Каждый ретрай — новый ключ — теряется смысл идемпотентности. - Сессионные ID: меняются между запросами от одного клиента, не отражают суть операции.
Жизненный цикл ключа
Что происходит в Pay Bot при получении запроса:
- Достаём
Idempotency-Keyиз заголовка. - Смотрим Redis: есть запись?
- Да, операция завершена → возвращаем сохранённый ответ.
- Да, операция в процессе (другой инстанс ещё работает) → ждём 1–2 секунды, потом 409
idempotency_in_progress. - Нет → атомарно сохраняем
Idempotency-Key → "in_progress"и продолжаем выполнение.
- После завершения сохраняем результат:
Idempotency-Key → response_body, status, headers.
Это значит, что параллельные запросы с одним ключом не создадут двух платежей даже если ваш балансировщик отправит их на разные инстансы Pay Bot.
Ошибка idempotency_key_mismatch
Иногда возвращается такая ошибка:
{
"error": {
"code": "idempotency_key_mismatch",
"message": "Idempotency-Key was used with different parameters"
}
}
Это значит: ключ уже был, но тело запроса другое. Pay Bot защищает вас от ситуации «отправил с суммой 5000, ретраил с 5001 — получил один платёж на непонятную сумму». Сообщение явное: либо измените ключ, либо приведите тело к исходному.
Что делать на стороне клиента
Минимальный безопасный паттерн:
def create_payment_safely(order):
if order.payment_id:
return order.payment_id # уже создавали
key = f"order:{order.id}:v1"
try:
resp = api.create_qr(amount=order.total, idempotency_key=key)
except NetworkError:
# Не знаем, дошёл ли запрос. Безопасно повторить с тем же ключом.
resp = api.create_qr(amount=order.total, idempotency_key=key)
order.payment_id = resp["operation_id"]
order.save()
return order.payment_id
Главные правила:
- Сохранять ключ до отправки, а не генерировать заново при ретрае.
- Один и тот же ключ на ретраи в пределах одной логической операции.
- После успешного ответа — пометить операцию в своей БД, чтобы не повторять без необходимости.
Идемпотентность для webhook'ов
С другой стороны, у вас как у получателя webhook'ов — та же проблема, но в зеркальном виде. Pay Bot может прислать одно событие два раза (например, если первый раз ответ потерялся). Вам нужно дедуплицировать на своей стороне.
Как обсуждалось в статье про webhooks, решение — хранить event.id и пропускать дубликаты:
def process_event(event):
if Event.exists(id=event["id"]):
return # уже обработано
with transaction():
Event.create(id=event["id"])
do_business_logic(event)
Это та же идемпотентность, только на стороне приёма.
Тестирование
Простейший тест на pytest:
def test_idempotent_create():
key = "test_" + str(uuid.uuid4())
r1 = api.create_qr(amount=100, idempotency_key=key)
r2 = api.create_qr(amount=100, idempotency_key=key)
assert r1["operation_id"] == r2["operation_id"]
def test_key_mismatch():
key = "test_" + str(uuid.uuid4())
api.create_qr(amount=100, idempotency_key=key)
with pytest.raises(ApiError, match="idempotency_key_mismatch"):
api.create_qr(amount=200, idempotency_key=key)
Запускайте регулярно — это страховка от случайной регрессии при будущих рефакторингах.
Граничные случаи
Что если ключ совпал случайно у разных бизнесов?
Идемпотентность изолирована по api_key. Ваш order:42:v1 не пересечётся с тем же ключом у другого клиента Pay Bot.
Что если запрос был отправлен 25 часов назад? Ключ устаревает за 24 часа. Pay Bot обработает запрос как новый. Поэтому ретраи имеют смысл в пределах окна, не дольше.
Что если хочется перезапустить операцию? Просто сгенерируйте новый ключ. В вашей БД это будет «вторая попытка» — это нормально, если первая явно провалилась.
Итого
Идемпотентность — это страховка от 90% проблем с двойными списаниями. Реализована в Pay Bot прозрачно: добавьте Idempotency-Key к каждому POST, сохраняйте его в своей БД до отправки и используйте один и тот же на все ретраи.
Если вы пишете платёжную интеграцию впервые, начните с обязательного слоя:
- Идемпотентность на исходящих POST.
- Дедупликация по
event.idна входящих webhook'ах. - Транзакционная запись результата в свою БД.
Дальше можно слой за слоем добавлять остальное — but the first three are non-negotiable.
Готовы попробовать Pay Bot?
Зарегистрируйтесь в кабинете — Sandbox доступен сразу. Или оставьте заявку, и менеджер настроит подключение Kaspi за вас.