Коротко
Идемпотентность — это когда двукратное выполнение действия даёт тот же результат, что и однократное. В платежах она нужна в двух местах:
- При создании счёта — заголовок
Idempotency-Key. Повтор с тем же ключом не создаёт новый счёт: возвращается прежний, с HTTP 200 и полемidempotentReplay: true. - При обработке вебхуков — выполнять работу один раз по паре
(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 | Один счёт в месяц |
| ❌ Случайный UUID | a3f1… | Новый на каждый запрос, ни от чего не защищает |
| ❌ Метка времени | 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-Key | externalOrderId | |
|---|---|---|
| Что делает | Останавливает повторный запрос | Связывает счёт с вашим заказом |
| Где передаётся | В 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_unknown | 502 | Kaspi не ответил, прошёл возврат или нет — неизвестно |
refund_pending_unknown | 409 | Результат предыдущего возврата ещё неизвестен |
Увидев любой из них, действуйте так:
- Не повторяйте возврат.
- Прочитайте статус счёта через
GET /api/v1/invoices/{id}— в ответе приходит и список возвратов. - Если статус
refundedилиpartially_refunded— возврат прошёл. Ничего не делайте. - Если статус всё ещё
paid— подождите и прочитайте снова. - Если ситуация долго не проясняется, напишите в поддержку: 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 проверяется отдельно. Следите на своей стороне, чтобы заказ попадал в список один раз: Массовое создание счетов.
Нужна ли идемпотентность в подписках? Подписка выставляет счета по расписанию на нашей стороне, это уже учтено. Вам нужно лишь идемпотентно обрабатывать вебхуки.