Qut Pay Сайт Кабинет База знаний Инструкции Документация API ҚАЗРУС
ГлавнаяБаза знаний → Справочник

Идемпотентность: защита от дублей

Обновлено: 2026-09-14 · Версия в Markdown

Коротко

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

  1. При создании счёта — заголовок Idempotency-Key. Повтор с тем же ключом не создаёт новый счёт: возвращается прежний, с HTTP 200 и полем idempotentReplay: true.
  2. При обработке вебхуков — выполнять работу один раз по паре (invoice.id, status).

Третье место — возвраты, и там логика другая: неизвестный результат повторять вслепую нельзя.

Как работает Idempotency-Key

curl -X POST https://api.qut.kz/api/v1/invoices \
  -H 'X-API-Key: qp_live_…' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-1001-pay' \
  -d '{ "amount": 2500, "externalOrderId": "1001" }'
ЗапросРезультат
Первый разHTTP 201, создан новый счёт
Повтор с тем же ключомHTTP 200, возвращается тот же счёт, idempotentReplay: true
С другим ключомHTTP 201, новый счёт

Без ключа каждый запрос создаёт новый счёт — поэтому двойное нажатие кнопки «оплатить» даёт два счёта.

В Node SDK это параметр idempotencyKey:

const inv = await qp.createInvoice({
  amount: 2500,
  description: 'Заказ №1001',
  externalOrderId: '1001',
  idempotencyKey: `order-${order.id}`,
});

Как составить ключ

Ключ — это устойчивая строка, описывающая одно конкретное действие. Правильная структура: номер заказа плюс действие.

ЧтоПримерПочему
✅ Заказ + действиеorder-1001-payОдин счёт на один заказ
✅ Заказ + действие + попыткаorder-1001-pay-2Когда покупатель осознанно просит новый счёт
✅ Подписка + периодsub-88-2026-09Один счёт в месяц
❌ Случайный UUIDa3f1…Новый на каждый запрос, ни от чего не защищает
❌ Метка времени1757167000Новый на каждый запрос, ни от чего не защищает
❌ Только номер заказа1001Тупик, если по заказу действительно нужен второй счёт

Главное правило: ключ должен вычисляться в вашей системе, то есть при повторной отправке получаться ровно таким же. Если вы генерируете его случайно на каждый запрос, он бесполезен.

Лучше хранить ключ рядом с заказом в базе: тогда после таймаута или перезапуска сервера вы повторите запрос именно с ним.

Что делать при таймауте

Самый опасный момент — запрос ушёл, а ответ не пришёл. Создан счёт или нет — неизвестно.

С Idempotency-Key ответ простой: повторите с тем же ключом. Если счёт уже создан, вернётся он; если нет — будет создан. В обоих случаях счёт останется один.

async function createInvoiceSafely(order) {
  const key = `order-${order.id}-pay`;
  for (let i = 0; i < 3; i++) {
    try {
      return await qp.createInvoice({ amount: order.total, externalOrderId: String(order.id), idempotencyKey: key });
    } catch (e) {
      if (i === 2) throw e;
      await new Promise((r) => setTimeout(r, 2 ** i * 1000));
    }
  }
}

Роль externalOrderId

externalOrderId — это ваш номер заказа. Он не является средством защиты: с одним и тем же значением можно создать сколько угодно счетов, никто не остановит.

Idempotency-KeyexternalOrderId
Что делаетОстанавливает повторный запросСвязывает счёт с вашим заказом
Где передаётсяВ HTTP-заголовкеВ теле счёта
Приходит ли в вебхукеНетДа
Годится для поискаНетДа
Защищает от дублейДаНет

Используйте оба: Idempotency-Key не даёт появиться дублю, externalOrderId помогает мгновенно найти нужный заказ при получении вебхука. Подробнее: Metadata и номер заказа.

Идемпотентность при обработке вебхуков

При ответе не 2xx мы повторяем запрос до 11 раз. Если сеть оборвалась или вы не успели ответить 200, обработчик получит событие второй раз. Поэтому выполняйте работу один раз по паре (invoice.id, status).

CREATE TABLE qutpay_events (
  invoice_id TEXT NOT NULL,
  status     TEXT NOT NULL,
  handled_at TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (invoice_id, status)
);
const ins = await db.query(
  'INSERT INTO qutpay_events (invoice_id, status) VALUES ($1, $2) ON CONFLICT DO NOTHING',
  [invoice.id, invoice.status],
);
if (ins.rowCount === 0) return res.sendStatus(200);  // уже обработано
await fulfil(invoice);

Почему одного invoice.id мало: по счёту приходит несколько разных статусов (pending, paid, затем refunded). Каждый нужно обработать, но каждый — ровно один раз.

Если вы параллельно опрашиваете статус (сценарии, чувствительные к задержке: вендинг, турникет), идемпотентность становится ещё важнее: два канала могут принести один результат дважды, а устройство не должно сработать дважды. Вместе с проверкой подписи: Безопасность вебхуков.

Идемпотентность при возвратах

Возврат — это движение денег, поэтому повторять его вслепую нельзя. Есть два кода ошибок, и оба означают «результат неизвестен»:

КодHTTPЗначение
refund_unknown502Kaspi не ответил, прошёл возврат или нет — неизвестно
refund_pending_unknown409Результат предыдущего возврата ещё неизвестен

Увидев любой из них, действуйте так:

  1. Не повторяйте возврат.
  2. Прочитайте статус счёта через GET /api/v1/invoices/{id} — в ответе приходит и список возвратов.
  3. Если статус refunded или partially_refunded — возврат прошёл. Ничего не делайте.
  4. Если статус всё ещё paid — подождите и прочитайте снова.
  5. Если ситуация долго не проясняется, напишите в поддержку: WhatsApp +7 778 881 3333, Telegram @qutpaybot.
try {
  await qp.refund(invoiceId, { amount });
} catch (e) {
  if (e.error === 'refund_unknown' || e.error === 'refund_pending_unknown') {
    await waitAndCheckState(invoiceId);   // не повторять
  } else {
    throw e;
  }
}

Подробнее: API возвратов и Как не вернуть деньги дважды.

Если счета уже дублируются

Появление дублей обычно означает цикл в коде или отсутствие идемпотентности. Срочные шаги: Счета дублируются.

Дубли быстро съедают и месячный лимит тарифа: лимит считается по созданным счетам, а не по оплаченным.

Вопросы и ответы

Есть ли у ключа срок жизни? Ключ хранится не бесконечно. Если отправить тот же заказ спустя месяцы, может создаться новый счёт — поэтому проверяйте состояние заказа ещё и в своей базе.

А если отправить тот же ключ с другой суммой? Вернётся прежний счёт. Если сумма действительно изменилась, используйте новый ключ, например order-1001-pay-2.

Работает ли это в песочнице? Да, точно так же.

А при массовом создании счетов? Каждый элемент POST /api/v1/invoices/bulk проверяется отдельно. Следите на своей стороне, чтобы заказ попадал в список один раз: Массовое создание счетов.

Нужна ли идемпотентность в подписках? Подписка выставляет счета по расписанию на нашей стороне, это уже учтено. Вам нужно лишь идемпотентно обрабатывать вебхуки.

Связанные статьи

Создание счёта: все поляПолный справочник по POST /api/v1/invoices — тип и ограничение каждого поля, все поля ответа, примеры на curl и Node, разница между qr и phone и список частых ошибок с решениями.Счета дублируютсяЕсли на один заказ выставляется несколько счетов, сначала надо остановить поток: удалите API-ключ — интеграция встанет в ту же секунду. Потом ищите причину и включайте идемпотентность.Metadata и номер заказаЧем externalOrderId отличается от metadata, как оба поля возвращаются в вебхуке, что можно класть в metadata и что туда нельзя класть никогда — с конкретными примерами.Безопасность вебхуков и проверка подписиКак устроена подпись, почему обязателен raw body, как проверять timestamp, примеры кода для Express, Laravel, Django и чистого Node, идемпотентная обработка и разбор частых ошибок.API возвратов — полный и частичный возвратСправочник по методу POST /invoices/{id}/refund: поля запроса, полный и частичный возврат, ограничение суммы, все коды ошибок, что делать при refund_unknown и какие события приходят после возврата.

Остались вопросы? WhatsApp +77788813333 · kazprose@gmail.com
Написать можно и из кабинета: Поддержка.

Qut Pay — независимый сервис, не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.