Коротко
Когда нужно выставить сразу много счетов, используйте POST /api/v1/invoices/bulk. В один запрос помещается от 1 до 100 элементов.
Главное свойство метода: каждый элемент проверяется и выполняется отдельно. Если у одного получателя неверный телефон, все остальные счета всё равно будут созданы. Поэтому ответ читают не как «прошло / не прошло», а поэлементно.
Запрос
POST /api/v1/invoices/bulk
X-API-Key: qp_live_…
Idempotency-Key: payroll-2026-09-14
Content-Type: application/json
{
"invoices": [
{ "amount": 12000, "kind": "phone", "customer": { "phone": "77011234567" }, "externalOrderId": "drv-101" },
{ "amount": 12000, "kind": "phone", "customer": { "phone": "77017654321" }, "externalOrderId": "drv-102" },
{ "amount": 8500, "kind": "qr", "description": "Сентябрь", "externalOrderId": "drv-103" }
]
}
Внутри каждого элемента — те же поля, что и при создании одиночного счёта: amount, kind, description, externalOrderId, customer, successUrl, failUrl, metadata. Ни новых полей, ни потерянных.
| Ограничение | Значение |
|---|---|
| Минимум элементов | 1 |
| Максимум элементов | 100 |
| Смешанные типы | В одном списке можно и qr, и phone |
| Ключ | Один ключ, одна организация, один кассир |
Ответ
Код ответа — 207: «у каждого элемента свой результат». Структура:
{
"total": 3,
"created": 2,
"failed": 1,
"results": [
{ "index": 0, "ok": true, "id": "inv_7Kd2", "status": "pending", "payUrl": "https://qut.kz/p/…", "externalOrderId": "drv-101" },
{ "index": 1, "ok": true, "id": "inv_7Kd3", "status": "pending", "payUrl": "https://qut.kz/p/…", "externalOrderId": "drv-102" },
{ "index": 2, "ok": false, "error": "invalid_amount", "message": "Неверная сумма", "externalOrderId": "drv-103" }
]
}
| Поле | Значение |
|---|---|
total | Сколько элементов отправлено |
created | Сколько создано успешно |
failed | Сколько упало |
results[].index | Порядковый номер в вашем списке, с нуля |
results[].ok | Прошёл ли этот элемент |
results[].error | Код ошибки, если упал |
Порядок массива results совпадает с порядком отправки, так что по index вы сопоставляете результат со своими данными. Но надёжнее в каждом элементе указывать externalOrderId — он возвращается и в ответе, и потом в вебхуке.
Обработка ошибок
Главный принцип: повторять нужно не всю пачку, а только упавшие элементы.
const res = await fetch(`${API}/invoices/bulk`, {
method: 'POST',
headers: {
'X-API-Key': KEY,
'Content-Type': 'application/json',
'Idempotency-Key': batchKey,
},
body: JSON.stringify({ invoices: batch }),
}).then((r) => r.json());
const retry = [];
for (const r of res.results) {
if (r.ok) {
saveInvoice(batch[r.index], r.id, r.payUrl);
continue;
}
if (['invalid_amount', 'invalid_phone', 'amount_must_be_whole_tenge'].includes(r.error)) {
// ошибка в самих данных — повтор бессмысленен, показываем оператору
reportToOperator(batch[r.index], r.error);
} else if (['invoice_create_failed', 'kaspi_error'].includes(r.error)) {
// временный сбой — можно повторить позже
retry.push(batch[r.index]);
}
}
Разделяйте ошибки по типу:
| Группа | Примеры кодов | Что делать |
|---|---|---|
| Ошибка в данных | invalid_amount, invalid_phone, invalid_kind, amount_must_be_whole_tenge | Не повторять без исправления |
| Временный сбой | invoice_create_failed, kaspi_error | Повторить с задержкой |
| Лимит | tariff_limit_reached, tariff_daily_burst | Остановиться, разобраться с тарифом |
| Привязка | kaspi_session_expired, no_provider | Восстановить кассира, затем повторить |
Есть и случаи, когда падает весь запрос целиком: неверный ключ (unauthorized), нет права (insufficient_scope), список пуст или в нём больше 100 элементов. Тогда results не приходит вовсе, возвращается обычное { error, message }.
Идемпотентность
В массовом запросе тоже работает заголовок Idempotency-Key. При повторной отправке с тем же ключом новые счета не создаются, а возвращается прежний результат.
Это особенно важно при обрыве связи: не получив ответ, вы не гадаете, создались счета или нет, а просто отправляете запрос заново с тем же ключом и читаете результат.
Делайте ключ осмысленным и привязанным к содержимому: payroll-2026-09-14 или orders-batch-4471. А externalOrderId внутри каждого элемента защищает на уровне отдельного счёта — лучше использовать оба механизма сразу. Подробнее: Счета дублируются.
Влияние на лимиты тарифа
Пачка — не льгота по лимитам. Каждый успешно созданный счёт считается и в месячном лимите, и в суточной защите отдельно.
| Тариф | Счетов в месяц | Суточная защита |
|---|---|---|
| Пробный | — | 50 |
| Старт | 800 | 200 |
| Бизнес | 4 000 | 1 500 |
| Про | 15 000 | 5 000 |
То есть на тарифе «Старт» больше 200 счетов в сутки не выставить — две пачки по 100 уже упираются в потолок. При достижении лимита элементы начнут падать с tariff_limit_reached или tariff_daily_burst. Это разные вещи: первое — месячный лимит вашего тарифа, второе — защита от зациклившейся интеграции.
Счета в песочнице в лимиты не входят, поэтому обкатайте пачку сначала ключом qp_test_….
Если Kaspi ограничивает частоту
Kaspi может ограничивать частоту обращений со своей стороны. Если после отправки пачки заметная часть элементов падает с invoice_create_failed или kaspi_error, это признак ограничения частоты.
Что делать:
- Уменьшите размер пачки. Вместо 100 отправляйте по 20-25 элементов.
- Ставьте паузу между пачками. Хватает 2-5 секунд.
- Повторяйте с растущей задержкой: 1, 2, 4, 8 секунд.
- Если пришёл
429с заголовкомRetry-After, дождитесь указанного времени. - Если падают вообще все элементы, остановитесь и не отправляйте заново — причина почти всегда в привязке кассира или в тарифе.
Порядок поиска причины массовых отказов разобран отдельно: Счета массово падают.
Когда пачка нужна, а когда нет
| Сценарий | Правильный инструмент |
|---|---|
| Таксопарк собирает месячный взнос с водителей | Пачка |
| Очередной платёж со слушателей курса | Пачка |
| Ежемесячные счета оптовым клиентам | Пачка |
| Один заказ на сайте | Одиночный POST /invoices |
| Периодические платежи, у каждого свой график | Подписка |
Если счетов десятки тысяч, ставьте пачки в очередь и отправляйте постепенно — попытка «выстрелить» всем объёмом сразу упрётся в суточную защиту.
Вопросы и ответы
Если один элемент упал, остальные отменяются? Нет. Созданное остаётся созданным. Транзакции здесь нет — пачка не работает по принципу «всё или ничего».
Вебхук приходит один на всю пачку? Нет, по каждому счёту приходят обычные события. Отдельно есть событие invoice.bulk — оно сообщает о самой пачке.
Что будет, если отправить больше 100? Запрос не примется целиком. Разбивайте список на части сами.
Можно ли в одной пачке использовать разных кассиров? Нет. Счета создаются через кассира, к которому привязан ключ. Для каждого кассира заведите свой ключ: API-ключи.
Видно ли такие счета в кабинете? Да, счета, созданные через API, отображаются в кабинете как обычно и попадают в экспорт.