# Массовое создание счетов — до 100 счетов в одном запросе

> Метод POST /api/v1/invoices/bulk: от 1 до 100 элементов за запрос, каждый проверяется отдельно, структура ответа, обработка ошибок поэлементно, идемпотентность и влияние на лимиты тарифа.

## Коротко

Когда нужно выставить сразу много счетов, используйте `POST /api/v1/invoices/bulk`. В один запрос помещается **от 1 до 100** элементов.

Главное свойство метода: **каждый элемент проверяется и выполняется отдельно**. Если у одного получателя неверный телефон, все остальные счета всё равно будут созданы. Поэтому ответ читают не как «прошло / не прошло», а поэлементно.

## Запрос

```http
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**: «у каждого элемента свой результат». Структура:

```json
{
  "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` — он возвращается и в ответе, и потом в вебхуке.

## Обработка ошибок

Главный принцип: **повторять нужно не всю пачку, а только упавшие элементы**.

```js
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` внутри каждого элемента защищает на уровне отдельного счёта — лучше использовать оба механизма сразу. Подробнее: [Счета дублируются](/kb/ru/duplicate-invoices).

## Влияние на лимиты тарифа

Пачка — не льгота по лимитам. **Каждый успешно созданный счёт** считается и в месячном лимите, и в суточной защите отдельно.

| Тариф | Счетов в месяц | Суточная защита |
|---|---|---|
| Пробный | — | 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`, это признак ограничения частоты.

Что делать:

1. **Уменьшите размер пачки.** Вместо 100 отправляйте по 20-25 элементов.
2. **Ставьте паузу между пачками.** Хватает 2-5 секунд.
3. **Повторяйте с растущей задержкой:** 1, 2, 4, 8 секунд.
4. Если пришёл `429` с заголовком `Retry-After`, дождитесь указанного времени.
5. Если падают вообще все элементы, **остановитесь** и не отправляйте заново — причина почти всегда в привязке кассира или в тарифе.

Порядок поиска причины массовых отказов разобран отдельно: [Счета массово падают](/kb/ru/mass-invoice-failures).

## Когда пачка нужна, а когда нет

| Сценарий | Правильный инструмент |
|---|---|
| Таксопарк собирает месячный взнос с водителей | Пачка |
| Очередной платёж со слушателей курса | Пачка |
| Ежемесячные счета оптовым клиентам | Пачка |
| Один заказ на сайте | Одиночный `POST /invoices` |
| Периодические платежи, у каждого свой график | Подписка |

Если счетов десятки тысяч, ставьте пачки в очередь и отправляйте постепенно — попытка «выстрелить» всем объёмом сразу упрётся в суточную защиту.

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

**Если один элемент упал, остальные отменяются?** Нет. Созданное остаётся созданным. Транзакции здесь нет — пачка не работает по принципу «всё или ничего».

**Вебхук приходит один на всю пачку?** Нет, по каждому счёту приходят обычные события. Отдельно есть событие `invoice.bulk` — оно сообщает о самой пачке.

**Что будет, если отправить больше 100?** Запрос не примется целиком. Разбивайте список на части сами.

**Можно ли в одной пачке использовать разных кассиров?** Нет. Счета создаются через кассира, к которому привязан ключ. Для каждого кассира заведите свой ключ: [API-ключи](/kb/ru/api-keys).

**Видно ли такие счета в кабинете?** Да, счета, созданные через API, отображаются в кабинете как обычно и попадают в экспорт.
