Блог
идемпотентность Idempotency-Key Kaspi Pay API надёжность

Идемпотентность в Kaspi Pay API: как безопасно повторять POST-запросы

Зачем нужен Idempotency-Key, как Pay Bot гарантирует одну операцию из одного запроса, и почему без идемпотентности вы рано или поздно списываете деньги дважды.

Pay Bot 12 апреля 2026 г. 5 мин чтения

Истории про двойные списания всегда заканчиваются одинаково: возвратами, потерей доверия и многочасовым разбирательством с поддержкой. Идемпотентность — это техника, которая делает такие истории невозможными. В Pay Bot она встроена в API на уровне дизайна — но воспользоваться ей нужно правильно.

Что такое идемпотентность

Операция называется идемпотентной, если повторное её выполнение даёт тот же результат, что и первое. В контексте платежей это значит: ваш сервер может повторить запрос десять раз, а платёж создастся только один.

GET идемпотентен по своей природе — он только читает. DELETE тоже (удалили один раз, второй раз ничего не происходит). Самые «опасные» — POST и PATCH, потому что они создают/изменяют состояние. Идемпотентность для них нужно добавлять сознательно.

Почему без неё нельзя

Представьте классический сценарий:

  1. Ваш бэкенд отправляет POST /v1/qr/create с суммой 50 000 ₸.
  2. Pay Bot создаёт операцию и пишет в БД.
  3. Pay Bot пишет ответ в TCP-сокет — но клиентская сеть рвётся.
  4. Ваш HTTP-клиент бросает ConnectionResetError.
  5. Ваш код «не получил ответа» и… повторяет запрос.

Без идемпотентности на стороне 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 при получении запроса:

  1. Достаём Idempotency-Key из заголовка.
  2. Смотрим Redis: есть запись?
    • Да, операция завершена → возвращаем сохранённый ответ.
    • Да, операция в процессе (другой инстанс ещё работает) → ждём 1–2 секунды, потом 409 idempotency_in_progress.
    • Нет → атомарно сохраняем Idempotency-Key → "in_progress" и продолжаем выполнение.
  3. После завершения сохраняем результат: 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

Главные правила:

  1. Сохранять ключ до отправки, а не генерировать заново при ретрае.
  2. Один и тот же ключ на ретраи в пределах одной логической операции.
  3. После успешного ответа — пометить операцию в своей БД, чтобы не повторять без необходимости.

Идемпотентность для 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, сохраняйте его в своей БД до отправки и используйте один и тот же на все ретраи.

Если вы пишете платёжную интеграцию впервые, начните с обязательного слоя:

  1. Идемпотентность на исходящих POST.
  2. Дедупликация по event.id на входящих webhook'ах.
  3. Транзакционная запись результата в свою БД.

Дальше можно слой за слоем добавлять остальное — but the first three are non-negotiable.

Готовы попробовать Pay Bot?

Зарегистрируйтесь в кабинете — Sandbox доступен сразу. Или оставьте заявку, и менеджер настроит подключение Kaspi за вас.