---
title: 'API інтеграції: облік, ERP і склад'
sidebar_label: API інтеграції
description: 'Серверний REST API модуля integration_api: залишки по складах, ціни, замовлення, ТТН, стрічка змін і вебхуки. Авторизація, підпис запитів, ідемпотентність, ліміти й повний довідник ендпоінтів.'
---

# API інтеграції

Модуль **API інтеграції** (`integration_api`) дає вашим скриптам і зовнішнім
системам безпечний спосіб працювати з магазином без адмінки. Це може бути
облік, ERP, складська програма, агрегатор цін чи служба доставки. Через API
вони можуть:

- читати каталог: товари, варіанти, категорії й бренди;
- читати й писати **залишки по складах**, абсолютним числом або дельтою;
- читати й писати **ціни**: базову, стару й типи цін;
- читати **замовлення**, змінювати їхній статус і ознаку оплати, ставити ТТН;
- забирати все, що змінилось з минулого запуску, зі **стрічки змін**, або
  одразу отримувати **вебхук**, щойно змінилось замовлення.

Це API «сервер — сервер». Він не для браузера чи мобільного застосунку: для
цього є [Headless API](./headless-api).

Модуль **безкоштовний і входить у ядро CMS9** з білда ядра 5.0.5-b562.
Ліцензія не потрібна, окремо в магазині модулів його не купують: він приходить
на кожен сайт з оновленням ядра й з'являється в **Адмінка → Модулі**. Поки ви
не створили токен, API на будь-який запит відповідає `401` і нічого не змінює.

:::tip[Якщо інтеграцію пише ШІ-агент]
Дайте агентові цю сторінку або її машинні копії:

- стаття як звичайний Markdown: [`/integration-api/integration-api.uk.md`](pathname:///integration-api/integration-api.uk.md);
  англійською: [`/integration-api/integration-api.md`](pathname:///integration-api/integration-api.md);
- контракт OpenAPI 3: [`/integration-api/openapi.yaml`](pathname:///integration-api/openapi.yaml).
  Ваш магазин віддає свою копію за адресою `/integration_api/v1/openapi.yaml`, з токеном;
- робочий клієнт на PHP з підписом і перевіркою вебхука: [`/integration-api/client.php.txt`](pathname:///integration-api/client.php.txt).

Розділ [Бриф для ШІ-агента](#бриф-для-ші-агента) наприкінці перелічує правила,
яких має триматися інтеграція.
:::

## Швидкий старт

1. **Адмінка → Модулі → API інтеграції** (`/admin/integration-api`). Створіть
   токен:
   - дайте йому назву;
   - позначте потрібні права, і не більше;
   - впишіть IP-адреси сервера, з якого працює ваш скрипт. Для будь-якого
     права на запис список обовʼязковий.
2. Адмінка **один раз** покаже два значення: токен `ik_…` і **секрет підпису**.
   Скопіюйте обидва одразу й передайте розробнику захищеним каналом. Магазин
   зберігає лише хеш токена, а секрет не зберігає взагалі, тож показати їх
   удруге неможливо. Якщо загубили, **ротуйте** токен.
3. Перевірте токен:

   ```bash
   curl -s https://ваш-магазин/integration_api/v1/ping \
        -H "Authorization: Bearer $INTAPI_TOKEN"
   ```

   У відповіді будуть назва токена, його права і `your_ip`, тобто адреса, яку
   бачить магазин. Саме її впишіть у список IP токена.
4. Перевірте код підпису через `POST /ping`, як показано в розділі
   [Підпис](#підпис). Кожен запис має бути підписаний.
5. Кожен запис спершу запускайте з `"dry_run": true`. Магазин перевірить
   запит і покаже, що зміниться, нічого не записавши.

## Основне

| | |
|---|---|
| Базова адреса | `https://ваш-магазин/integration_api/v1` |
| Протокол | Лише HTTPS. Звичайний HTTP отримує `403 HTTPS_REQUIRED`. |
| Формат | JSON в обидва боки, UTF-8. Разом із тілом шліть `Content-Type: application/json`. |
| Авторизація | `Authorization: Bearer ik_…`. Якщо проксі зрізає `Authorization`, шліть `X-Api-Key: ik_…`. **Ніколи** в рядку адреси. |
| Мова назв | `?locale=ua` або `?locale=en`. Без параметра — основна мова магазину. |
| Час | У відповідях — UTC в ISO-8601 із `Z`, наприклад `2026-09-27T07:02:11Z`. Фільтри приймають ISO-8601 або unix-секунди. Час **без** зміщення читається в часовому поясі магазину, тож завжди шліть `Z` або зміщення. |
| Булеві параметри адреси | `active`, `paid` і `dry_run` приймають `1`, `0`, `true` або `false`. Будь-що інше відхиляється з `400 INVALID_FIELD`. |
| Ідентифікатори | `sku`, `barcode` і `external_id` порівнюються без урахування регістру, пробіли з країв ігноруються. |
| Контракт | `GET /openapi.yaml` (OpenAPI 3.0) |

### Конверт відповіді

Кожна відповідь має однакову форму, зокрема помилки й невідомі шляхи:

```json
{ "success": true,  "data": { … }, "error": null }
{ "success": false, "data": null,  "error": { "code": "FORBIDDEN", "message": "…", "details": { … } } }
```

Розгалужуйтесь за `error.code`. Поле `message` написане для людей і може
змінюватись.

### Права (scopes)

| Право | Що дає |
|---|---|
| `catalog:read` | `/categories`, `/brands`, `/products`, `/variants`; рядки `product` у стрічці змін |
| `stock:read` | `GET /stock`, `include=stock`; рядки `stock` у стрічці |
| `stock:write` | `PUT /stock`, `POST /stock/adjust` |
| `prices:read` | `GET /prices`, `include=price_types`; рядки `price` у стрічці |
| `prices:write` | `PUT /prices` |
| `orders:read` | `/orders`, `/order-statuses`, `/payment-methods`, `/delivery-methods`; рядки `order` у стрічці |
| `orders:write` | `PATCH /orders/{id}/status`, `…/paid`, `…/ttn` |
| `customers:pii` | додає до замовлень блок `customer`: імʼя, телефон, email, адреса, коментар |
| `webhooks:manage` | `GET/POST /webhooks`, `DELETE /webhooks/{id}` |

Права на запис (`stock:write`, `prices:write`, `orders:write` і
`webhooks:manage`) вимагають списку дозволених IP. Токен лише на читання теж
може його мати: список діє завжди, коли він не порожній. Запис у списку — одна
адреса або діапазон, не ширший за `/16` для IPv4 чи `/32` для IPv6. Токенові
також можна поставити термін дії.

`/ping`, `/openapi.yaml`, `/warehouses`, `/currencies`, `/price-types` і
`/changes` працюють із будь-яким токеном.

:::note[Магазин за CDN чи балансувальником]
Перевірка IP бере адресу, яку бачить магазин. Якщо магазин стоїть за CDN чи
балансувальником, адміністратор магазину має вписати проксі в налаштування CMS
`app.proxyIPs` разом із заголовком, який проксі **перезаписує** (наприклад,
`X-Forwarded-For` від власного балансувальника магазину). Інакше всі запити
виглядатимуть так, ніби йдуть із проксі. `GET /ping` показує адресу, яку бачить
магазин.
:::

Кожній системі давайте окремий токен. Тоді журнал покаже, хто що зробив, і
одну систему можна відключити, не зачепивши решту.

### Порядок перевірок і коди помилок

Магазин перевіряє запит у такому порядку. Відповідає перша перевірка, що не
пройшла.

| # | Перевірка | HTTP | `error.code` |
|---|---|---|---|
| 1 | Модуль вимкнено | 404 | `NOT_FOUND` |
| 2 | Не HTTPS | 403 | `HTTPS_REQUIRED` |
| 3 | IP заблоковано після серії невдалих входів | 429 | `TOO_MANY_FAILURES` (+ `Retry-After`) |
| 4 | Токена немає, він хибний, вимкнений, відкликаний чи прострочений | 401 | `UNAUTHORIZED` (навмисно однаково для всіх випадків) |
| 5 | IP немає в списку токена | 403 | `IP_NOT_ALLOWED` |
| 6 | Бракує права | 403 | `FORBIDDEN` |
| 7 | Лише запис: магазин у режимі «лише читання» | 503 | `READ_ONLY` |
|   | Лише запис: тіло завелике (типово 2 МБ) | 413 | `BODY_TOO_LARGE` |
|   | Лише запис: підпис | 401 | `SIGNATURE_MISSING`, `SIGNATURE_EXPIRED`, `SIGNATURE_INVALID`, `SIGNATURE_REPLAYED` |
| 8 | Ліміт запитів | 429 | `RATE_LIMITED` (+ `Retry-After`) |

Після цих перевірок ідуть помилки самого запиту:

| HTTP | Код | Що означає |
|---|---|---|
| 400 | `INVALID_JSON` | Тіло не є JSON-обʼєктом |
| 400 | `UNKNOWN_FIELD` | У тілі поле, якого ендпоінт не знає. Одруківки не ігноруються. |
| 400 | `INVALID_FIELD` | Параметр або поле має хибний тип чи формат, зокрема `dry_run` чи `all_or_nothing`, що не є булевим |
| 400 | `INVALID_CURSOR` | Курсор узято не з `next_cursor` |
| 400 | `ITEMS_REQUIRED` | Пакет без `items` |
| 400 | `INVALID_ROW` | Рядок пакета не є обʼєктом |
| 400 | `BAD_IDEMPOTENCY_KEY` | `Idempotency-Key` не відповідає `^[A-Za-z0-9_.:-]{8,64}$` |
| 404 | `NOT_FOUND` | Такого обʼєкта чи ендпоінта немає (зокрема `GET /orders/{id}` для невідомого замовлення) |
| 404 | `ORDER_NOT_FOUND` | Замовлення з `PATCH /orders/{id}/…` не існує |
| 409 | `IDEMPOTENCY_CONFLICT` | Той самий `Idempotency-Key`, але інший запит |
| 409 | `IDEMPOTENCY_IN_PROGRESS` | Запит із цим ключем ще виконується (`Retry-After: 2`) |
| 413 | `BATCH_TOO_LARGE` | Рядків більше, ніж дозволено (типово 1000) |
| 422 | `VALIDATION_FAILED` | Жоден рядок пакета не вдалося застосувати, або `all_or_nothing` натрапив на поганий рядок. Помилки рядків — у `details.rows`. |
| 422 | свій для ендпоінта | Наприклад `INVALID_TTN` чи `TRACKING_OWNED_BY_MODULE`; див. опис ендпоінта |
| 500 | `INTERNAL_ERROR` | Збій на сервері. Нічого не застосовано; повторіть пізніше. |

### Ліміти

| Ліміт | Типово |
|---|---|
| Читання | 300 на хвилину на токен |
| Запис | 60 на хвилину на токен |
| Рядків в одному пакеті | 1000 |
| Тіло запиту | 2 МБ |
| Невдалих входів до блокування IP | 20 за фіксоване 15-хвилинне вікно, далі блок до кінця цього вікна |
| Вікно підпису | ±300 с (власник магазину може змінити; щонайменше 30 с) |

Власник магазину може змінити їх у блоці **Безпека й ліміти** на екрані
модуля.

Кожна відповідь несе `X-RateLimit-Limit`, `X-RateLimit-Remaining` і
`X-RateLimit-Reset`. Ліміти рахуються по календарних хвилинах, а
`X-RateLimit-Reset` — це кількість секунд до початку наступної хвилини.
На `429` зачекайте `Retry-After` секунд.

## Підпис

Кожен **запис** має бути підписаний: `PUT`, `POST`, `PATCH` і `DELETE`.
Читання підписувати не обовʼязково. Підпис доводить, що запит надіслав
власник секрету і що дорогою його ніхто не змінив.

Заголовки:

```
X-Timestamp: <unix-секунди, зараз>
X-Signature: <hex HMAC-SHA256 від канонічного рядка, ключ — секрет підпису>
```

Канонічний рядок — пʼять рядків через `\n`, без переносу в кінці:

```
METHOD            великими літерами: PUT
PATH              повний шлях від першого слеша: /integration_api/v1/stock
QUERY             рядок запиту точно як надіслано, без «?»; порожній рядок, якщо його нема
TIMESTAMP         те саме значення, що в X-Timestamp
SHA256(BODY)      hex у нижньому регістрі від SHA-256 сирих байтів тіла; для порожнього тіла — SHA-256 від ""
```

На чому найчастіше помиляються:

- **Підписуйте саме ті байти, які надсилаєте.** Серіалізуйте тіло один раз,
  підпишіть цей рядок і надішліть його ж. Якщо дозволити HTTP-бібліотеці
  заново серіалізувати словник чи обʼєкт, хеш зміниться.
- **Підписуйте рядок запиту таким, яким він іде в мережу.** Зберіть його один
  раз, вставте в адресу й підпишіть той самий рядок. Магазин його не сортує й
  не перекодовує: `b=2&a=1` і `a=1&b=2` — різні рядки з різними підписами.
- **Секрет** — це hex-рядок, який показала адмінка. Він іде ключем HMAC як є;
  декодувати hex не треба.
- Час має бути в межах **±300 с** від годинника магазину (типове значення),
  тож тримайте NTP увімкненим.
- Підпис приймається **один раз**. Повтор підписуйте заново з новим часом,
  тоді й підпис буде новим. Щоб повторений запис не застосувався двічі,
  використовуйте `Idempotency-Key` (див. нижче).
- **Два однакові запити в ту саму секунду** мають однаковий підпис, тож другий
  отримає `401 SIGNATURE_REPLAYED`. Якщо скрипт може надіслати той самий запис
  двічі за секунду, додайте унікальний параметр, наприклад `?nonce=<випадкове>`
  (він підписується, як і решта рядка запиту), або дочекайтеся наступної
  секунди.
- Ротація токена в адмінці змінює й секрет.

`POST /ping` з будь-яким тілом перевіряє ваш підпис, не торкаючись даних.
Працює з будь-яким правом, а його невдалі підписи не рахуються в блокування IP,
тож на ньому безпечно налагоджувати:

```json
{ "success": true, "data": { "ok": true, "signature": "valid", "body_sha256": "…" }, "error": null }
```

Якщо підпис хибний, `body_sha256` не повертається. Звірте свій хеш тіла й
канонічний рядок із правилами вище.

### PHP

```php
function intapi_call(string $method, string $path, string $query = '', ?array $body = null, ?string $idemKey = null): array
{
    $base   = getenv('INTAPI_BASE');   // https://ваш-магазин/integration_api/v1
    $token  = getenv('INTAPI_TOKEN');
    $secret = getenv('INTAPI_SECRET');

    $raw  = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    $url  = rtrim($base, '/') . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : '');
    $ts   = (string) time();
    $sign = strtoupper($method) . "\n" . parse_url($url, PHP_URL_PATH) . "\n" . $query . "\n" . $ts . "\n" . hash('sha256', $raw);

    $headers = [
        'Authorization: Bearer ' . $token,
        'Accept: application/json',
        'X-Timestamp: ' . $ts,
        'X-Signature: ' . hash_hmac('sha256', $sign, $secret),
    ];
    if ($raw !== '')       { $headers[] = 'Content-Type: application/json'; }
    if ($idemKey !== null) { $headers[] = 'Idempotency-Key: ' . $idemKey; }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => $raw !== '' ? $raw : null,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
    ]);
    $resp   = (string) curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return ['status' => $status, 'body' => json_decode($resp, true)];
}

// intapi_call('POST', 'ping', '', ['hello' => 'world']);
// intapi_call('GET', 'stock', 'sku=' . rawurlencode('RC-1234'));
```

### Python 3

```python
import hashlib, hmac, json, os, time, urllib.parse, requests

BASE   = os.environ["INTAPI_BASE"].rstrip("/")   # https://ваш-магазин/integration_api/v1
TOKEN  = os.environ["INTAPI_TOKEN"]
SECRET = os.environ["INTAPI_SECRET"].encode()

def call(method, path, params=None, body=None, idem_key=None):
    query = urllib.parse.urlencode(params or {}, quote_via=urllib.parse.quote)
    raw   = b"" if body is None else json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
    url   = f"{BASE}/{path.lstrip('/')}" + (f"?{query}" if query else "")
    ts    = str(int(time.time()))
    canon = "\n".join([method.upper(), urllib.parse.urlsplit(url).path, query, ts, hashlib.sha256(raw).hexdigest()])
    headers = {
        "Authorization": f"Bearer {TOKEN}",
        "Accept": "application/json",
        "X-Timestamp": ts,
        "X-Signature": hmac.new(SECRET, canon.encode(), hashlib.sha256).hexdigest(),
    }
    if raw:
        headers["Content-Type"] = "application/json"
    if idem_key:
        headers["Idempotency-Key"] = idem_key
    # Передавайте готову адресу й сирі байти, а не params=/json=: requests перекодує їх по-своєму.
    r = requests.request(method, url, data=raw or None, headers=headers, timeout=60)
    return r.status_code, r.json()
```

### Node.js 18+

```js
import crypto from 'node:crypto';

const BASE = process.env.INTAPI_BASE.replace(/\/$/, '');   // https://ваш-магазин/integration_api/v1

export async function call(method, path, params = {}, body = null, idemKey = null) {
  const query = new URLSearchParams(params).toString();
  const raw = body === null ? '' : JSON.stringify(body);
  const url = `${BASE}/${path.replace(/^\//, '')}${query ? '?' + query : ''}`;
  const ts = String(Math.floor(Date.now() / 1000));
  const canon = [method.toUpperCase(), new URL(url).pathname, query, ts,
    crypto.createHash('sha256').update(raw, 'utf8').digest('hex')].join('\n');
  const headers = {
    Authorization: `Bearer ${process.env.INTAPI_TOKEN}`,
    Accept: 'application/json',
    'X-Timestamp': ts,
    'X-Signature': crypto.createHmac('sha256', process.env.INTAPI_SECRET).update(canon).digest('hex'),
  };
  if (raw) headers['Content-Type'] = 'application/json';
  if (idemKey) headers['Idempotency-Key'] = idemKey;
  const res = await fetch(url, { method, headers, body: raw || undefined });
  return { status: res.status, body: await res.json() };
}
```

### Shell (curl + openssl)

```bash
BODY='{"hello":"world"}'
TS=$(date +%s)
P=/integration_api/v1/ping
SIG=$(printf 'POST\n%s\n\n%s\n%s' "$P" "$TS" "$(printf '%s' "$BODY" | sha256sum | cut -d' ' -f1)" \
      | openssl dgst -sha256 -hmac "$INTAPI_SECRET" | sed 's/^.* //')
curl -s "https://ваш-магазин$P" -H "Authorization: Bearer $INTAPI_TOKEN" \
     -H "X-Timestamp: $TS" -H "X-Signature: $SIG" -H 'Content-Type: application/json' -d "$BODY"
```

## Безпечний запис

### `dry_run`

Передайте `"dry_run": true` у тілі або `?dry_run=1` в адресі будь-якого
запису. У тілі це має бути булеве значення JSON, в адресі — `1`, `0`, `true`,
`false` або порожньо. Будь-що інше, наприклад `"yes"` чи `"1"` у тілі,
відхиляється з `400 INVALID_FIELD`: одруківка ніколи не перетворить пробний
прогін на справжній запис. Магазин виконає всі перевірки й поверне той самий звіт по рядках.
Рядки матимуть `would_update` замість `updated`, а `applied` буде `false`.
Нічого не записується, жодна подія не спрацьовує, жоден вебхук не йде.
Ганяйте новий скрипт із `dry_run`, доки звіт не виглядатиме правильно.

### `Idempotency-Key`

Для записів, які не можна виконати двічі (передусім `POST /stock/adjust`, де
дві дельти `-2` знімуть 4), шліть унікальний ключ на кожну логічну операцію:

```
Idempotency-Key: stock-2026-09-27T10:00-batch-17
```

- Ключ — 8–64 символи з `A–Z a–z 0–9 _ . : -`, окремий для кожного токена.
- Повтор із тим самим ключем і **тим самим запитом** запис не повторює. Він
  повертає збережену відповідь із заголовком `Idempotent-Replayed: true`.
- Той самий ключ з **іншим** запитом отримає `409 IDEMPOTENCY_CONFLICT`.
- Якщо перший запит ще виконується, прийде `409 IDEMPOTENCY_IN_PROGRESS`.
  Зачекайте `Retry-After` секунд і повторіть.
- Ключі живуть 24 години. Відповіді на `dry_run` і помилки `5xx` не
  зберігаються, тож їх можна повторювати з тим самим ключем.

Повтор однаково треба **підписати заново** зі свіжим часом. Безпечним його
робить ключ, а не підпис.

### Пакети

`PUT /stock`, `POST /stock/adjust` і `PUT /prices` приймають пакет:

```json
{
  "items": [ { "sku": "RC-1234", "warehouse_id": 1, "quantity": 12 }, … ],
  "dry_run": false,
  "all_or_nothing": false
}
```

Кожен рядок називає **рівно один** варіант одним із полів:

- `variant_id` — id у магазині. Він завжди однозначний.
- `sku` — артикул варіанта, як у формі товару.
- `barcode` — штрихкод.
- `external_id` — id з вашої облікової системи.

Якщо `sku`, `barcode` чи `external_id` збігається з кількома варіантами,
рядок відхиляється з `AMBIGUOUS`, і жоден із них не змінюється. Для таких
рядків шліть `variant_id`: знайдіть його через `GET /variants?sku=…`, який
повертає всі збіги.

Типово добрі рядки застосовуються, а погані потрапляють у звіт. З
`"all_or_nothing": true` один поганий рядок відхиляє весь пакет із
`422 VALIDATION_FAILED`. Той самий `422` прийде, якщо не вдалося застосувати
жодного рядка.

Добрі рядки пакета пишуться **однією транзакцією бази**. Якщо запит обірвався
посередині (таймаут, збій сервера), з нього не застосовано нічого, і повтор із
тим самим `Idempotency-Key` не може застосувати пів пакета двічі.

Результат — звіт по кожному рядку:

```json
{
  "success": true,
  "data": {
    "dry_run": false, "applied": true,
    "total": 3, "changed": 1, "unchanged": 1, "failed": 1,
    "rows": [
      { "index": 0, "variant_id": 2211, "warehouse_id": 1, "old": 7, "new": 12, "status": "updated" },
      { "index": 1, "variant_id": 2212, "warehouse_id": 1, "old": 0, "new": 0,  "status": "unchanged" },
      { "index": 2, "status": "error", "error": { "code": "AMBIGUOUS", "message": "2 variants share this sku; send variant_id" } }
    ]
  },
  "error": null
}
```

`index` — позиція рядка у ваших `items`. `status` рядка буває `updated`,
`unchanged`, `would_update` (з `dry_run`), `skipped` (добрий рядок, не
застосований через `all_or_nothing`) або `error`.

Коди помилок рядка:

| Код | Що означає |
|---|---|
| `IDENTIFIER_REQUIRED` | У рядку немає ідентифікатора, або їх кілька |
| `VARIANT_NOT_FOUND` | Варіанта з таким ідентифікатором немає |
| `AMBIGUOUS` | Збігається кілька варіантів; шліть `variant_id` |
| `WAREHOUSE_NOT_FOUND` | `warehouse_id` не передано або такого складу немає |
| `OUT_OF_RANGE` | Кількість поза 0..10 000 000, дельта 0 або результат нижче нуля |
| `DUPLICATE_ROW` | Той самий варіант і склад (чи тип ціни) двічі в одному пакеті |
| `PRICE_GUARD` | Ціна змінюється більш ніж на 50 % або стає 0, а `"force": true` немає |
| `PRICE_TYPE_NOT_FOUND` | Невідомий `price_type_id` |
| `INVALID_FIELD`, `UNKNOWN_FIELD` | Поле хибного типу або поле, якого ендпоінт не знає |

### Вимикач «Лише читання»

Власник магазину може одним прапорцем в адмінці перевести API в режим
**лише читання**. Тоді кожен запис отримує `503 READ_ONLY`, а читання
працює далі. Сприймайте `503 READ_ONLY` як «зупинись і повтори пізніше», а не
як баг.

## Довідник ендпоінтів

Усі шляхи — відносно `https://ваш-магазин/integration_api/v1`.

### Службові й довідники

| Метод і шлях | Право | Повертає |
|---|---|---|
| `GET /ping` | будь-яке | `{ok, version, token{name, prefix, scopes, expires_at}, your_ip, server_time}` |
| `POST /ping` | будь-яке, з підписом | `{ok, signature: "valid", body_sha256}` |
| `GET /openapi.yaml` | будь-яке | контракт OpenAPI з базовою адресою цього магазину |
| `GET /warehouses` | будь-яке | `[{id, name, address, active, is_default, show_on_storefront, position}]` |
| `GET /currencies` | будь-яке | `[{id, code, name, symbol, main, is_default, rate}]` |
| `GET /price-types` | будь-яке | `[{id, name, active, currency, position}]` |
| `GET /order-statuses` | `orders:read` | `[{id, name, color, is_cancel, stock_action}]` |
| `GET /payment-methods` | `orders:read` | `[{id, name, …}]` |
| `GET /delivery-methods` | `orders:read` | `[{id, name, …}]` |

Довідники читайте один раз на початку запуску. Id статусів, складів і типів
цін — саме те, що приймають інші ендпоінти.

### Каталог

**`GET /products`** (`catalog:read`) повертає товари з варіантами, від
найдавнішої зміни.

| Параметр | |
|---|---|
| `updated_since` | ISO-8601 або unix-секунди. Лише товари, змінені відтоді. |
| `category_id`, `brand_id` | Фільтр за категорією чи брендом |
| `active` | `1`/`true` або `0`/`false` |
| `include` | Список через кому: `stock` (потрібне `stock:read`), `price_types` (потрібне `prices:read`) |
| `cursor`, `limit` | `limit` 1..500, типово 100. Ідіть за `next_cursor`, доки він не стане `null`. |

```json
{ "items": [ {
    "id": 512, "name": "…", "active": true, "archive": false, "url": "…",
    "category_id": 14, "category_ids": [14, 3], "brand_id": 7, "external_id": null,
    "created_at": "2025-04-01T06:12:00Z", "updated_at": "2026-09-20T15:03:11Z",
    "variants": [ {
      "id": 2211, "product_id": 512, "sku": "RC-1234", "barcode": "4820000000000", "external_id": null,
      "name": "2 кг", "price": 1299, "currency": "UAH", "price_storefront": 1299, "old_price": 1499,
      "stock": 12, "position": 0,
      "warehouses":  [ { "warehouse_id": 1, "quantity": 12 } ],
      "price_types": [ { "price_type_id": 2, "price": 1190 } ]
    } ]
  } ],
  "next_cursor": "…" }
```

- `warehouses` є лише з `include=stock`, а `price_types` — лише з
  `include=price_types`.
- `price` — у власній валюті варіанта (`currency`), як введено у формі
  товару. `price_storefront` — те, що показує вітрина, в основній валюті
  магазину.
- `stock` — сума по всіх складах.

**`GET /products/{id}`** повертає один товар у тій самій формі; `include` тут
теж працює.

**`GET /variants`** шукає варіанти за **одним** видом ідентифікатора:
`ids=1,2,3`, `sku=A,B`, `barcode=…` або `external_id=…`. Значення через кому,
до 1000. Повертаються всі збіги, тож дублі артикулів тут видно. Цим
ендпоінтом зіставляйте свої коди з `variant_id` перед записом.

**`GET /categories`** повертає дерево категорій плоским списком, а
**`GET /brands`** — бренди. Обидва потребують `catalog:read`.

### Залишки

Магазин тримає залишки **по складах**. Загальний залишок варіанта (`stock`)
завжди дорівнює сумі його рядків по складах, і магазин перераховує його після
кожного запису. Пишіть у склади, а не в загальну суму.

**`GET /stock`** (`stock:read`) приймає `ids`, `sku`, `barcode` або
`external_id` (списки через кому). Без них обходить усі варіанти з `cursor`
і `limit` (1..1000, типово 500).

```json
{ "items": [ { "variant_id": 2211, "product_id": 512, "sku": "RC-1234", "total": 12,
               "warehouses": [ { "warehouse_id": 1, "quantity": 12 } ] } ],
  "next_cursor": "2211" }
```

**`PUT /stock`** (`stock:write`, з підписом) ставить абсолютні кількості:

```json
{ "items": [ { "sku": "RC-1234", "warehouse_id": 1, "quantity": 12 } ], "dry_run": true }
```

**`POST /stock/adjust`** (`stock:write`, з підписом) змінює кількість на
дельту. Рядки блокуються на час запису, тож два скрипти, що одночасно
змінюють той самий склад, не загублять жодної зміни. Результат нижче нуля
відхиляється з `OUT_OF_RANGE`, і рядок лишається як був; якщо інший запис
встиг першим, повідомлення буде `Stock changed meanwhile`. Завжди шліть
`Idempotency-Key`.

```json
{ "items": [ { "variant_id": 2211, "warehouse_id": 1, "delta": -2 } ] }
```

Після запису магазин робить те саме, що й після збереження в адмінці:

- оновлює товарні фіди;
- оновлює пошуковий індекс;
- чистить кеш сторінок;
- додає рядок у стрічку змін.

### Ціни

**`GET /prices`** (`prices:read`) приймає `ids`, `sku`, `barcode` або
`external_id`. Для кожного варіанта повертає
`{variant_id, product_id, sku, price, old_price, currency, price_storefront, price_types[]}`.

**`PUT /prices`** (`prices:write`, з підписом):

```json
{ "items": [
    { "sku": "RC-1234", "price": 1299.00, "old_price": 1499.00 },
    { "sku": "RC-1234", "price_type_id": 2, "price": 1190.00 },
    { "variant_id": 2215, "price": 0, "force": true }
] }
```

- `price` — у **власній валюті варіанта**: тій, що у формі товару й у полі
  `currency`. Якщо варіант має ціну в USD, шліть USD; магазин сам перерахує
  ціну вітрини за своїм курсом.
- Рядок без `price_type_id` ставить базову ціну і, за бажання, `old_price`.
  Рядок із `price_type_id` ставить ціну цього типу; `old_price` там не
  дозволено.
- Ціна 0..100 000 000, до 2 знаків після коми.
- **Запобіжник ціни.** Зміна більш ніж на 50 % або ціна 0 відхиляються з
  `PRICE_GUARD`, якщо в рядку немає `"force": true`. Це захищає магазин від
  плутанини одиниць (копійки й гривні) чи порожньої колонки в прайсі. Не
  ставте `force` на всі рядки за замовчуванням.

### Замовлення

**`GET /orders`** (`orders:read`):

| Параметр | |
|---|---|
| `updated_since` | ISO-8601 або unix-секунди; сортування за часом зміни |
| `created_since` | Те саме, але за часом створення; для «лише нових замовлень» |
| `status` | Id статусів через кому |
| `paid` | `1`/`true` або `0`/`false` |
| `cursor`, `limit` | `limit` 1..200, типово 50. Ідіть за `next_cursor`. Курсор привʼязаний до сортування: з `created_since` список іде за часом створення, інакше — за часом зміни. Не змішуйте курсори між ними. |

:::info[`updated_since`, стрічка змін і вебхуки]
`updated_since` іде за власним часом зміни замовлення, і будь-яка зміна статусу
чи оплати його зсуває: в адмінці, платіжним шлюзом, через API, а також
модулями CRM і SMS (RetailCRM 1.1.7+, SMS-підтвердження 1.0.6+, Partmono
1.0.2+). [Стрічка змін](#стрічка-змін) і [вебхуки](#вебхуки) повідомляють про
зміни одразу, але лише про ті, що йдуть через події замовлення магазину:
адмінка, платіжні шлюзи, автоТТН Нової пошти, статуси з SalesDrive і цей API.
Статус, який поставив вебхук RetailCRM чи SMS-підтвердження, або прапорець
оплати від Partmono чи SalesDrive видно лише в `updated_since`. Тож стежте за
змінами вебхуками чи стрічкою, а `GET /orders?updated_since=…` час від часу
запускайте як страховку.
:::

Замовлення:

```json
{
  "id": 7170, "external_id": null,
  "status": { "id": 1, "name": "Нове" },
  "paid": false, "paid_at": null,
  "total": 1349, "origin_total": 1499, "discount": 150, "delivery_price": 0,
  "delivery_method": { "id": 3, "name": "Нова Пошта" },
  "payment_method":  { "id": 2, "name": "Оплата при отриманні" },
  "user_id": 0,
  "created_at": "2026-09-27T07:02:11Z", "updated_at": "2026-09-27T07:02:11Z",
  "items": [ { "product_id": 512, "variant_id": 2211, "sku": "RC-1234", "name": "…", "variant_name": "2 кг",
               "price": 1349, "origin_price": 1499, "quantity": 1 } ],
  "tracking": { "carrier": "nova_poshta", "number": "20450000000001", "owner": "nova_poshta" },
  "customer": { "name": "…", "surname": "…", "phone": "…", "email": "…", "deliver_to": "…", "comment": "…" }
}
```

`customer` є лише тоді, коли токен має `customers:pii`. `tracking` дорівнює
`null`, якщо ТТН немає.

**`GET /orders/{id}`** повертає одне замовлення в тій самій формі.

**`PATCH /orders/{id}/status`** (`orders:write`, з підписом):

```json
{ "status_id": 14, "comment": "Відвантажено зі складу", "dry_run": true }
```

Відповідь: `{order_id, dry_run, changed, old_status, new_status, paid}`.
Невідомий `status_id` дає `422 STATUS_NOT_FOUND`, невідоме замовлення —
`404 ORDER_NOT_FOUND`.

:::warning[Зміна статусу — не просто поле]
Вона робить рівно те саме, що зміна статусу в адмінці, з усіма наслідками,
які налаштовано в магазині: синхронізація з CRM, фіскальний чек, SMS і
Telegram покупцеві, складські дії статусу (наприклад, повернення на склад при
скасуванні). Спершу запускайте з `dry_run` і ніколи не шліть статус «про всяк
випадок».
:::

**`PATCH /orders/{id}/paid`** (`orders:write`, з підписом) приймає
`{"paid": true, "dry_run": false}` і відповідає так само. Позначка «оплачено»
може запустити фіскальний чек, якщо магазин фіскалізує оплати, тож узгодьте
це з власником магазину.

**`PATCH /orders/{id}/ttn`** (`orders:write`, з підписом) ставить ТТН:

```json
{ "number": "20450000000001", "carrier": "", "dry_run": false }
```

- **Відправлення Нової Пошти** — це замовлення, де спосіб доставки Нова Пошта
  або вже є накладна НП. Для них:
  - номер іде в накладну модуля Нової Пошти;
  - він має бути з 14 цифр, інакше відповідь `422 INVALID_TTN`;
  - `carrier` ігнорується;
  - далі модуль сам відстежує посилку і **може змінити статус замовлення**
    за своєю мапою статусів, як і для накладної, створеної в адмінці;
  - очистити номер не можна: `422 TRACKING_CLEAR_NOT_SUPPORTED`. Видаліть
    накладну в адмінці.
- **Відправлення належить модулю іншого перевізника:**
  `422 TRACKING_OWNED_BY_MODULE`.
- **Будь-яке інше замовлення:** номер разом із `carrier` іде в ручне поле ТТН
  замовлення. Порожній `number` очищає його.

### Стрічка змін

**`GET /changes`** працює з будь-яким токеном, але віддає лише сутності, які
токен має право читати. Повертає те, що змінилось після вашого курсора, від
найдавнішого, лише id:

```json
{ "items": [
    { "seq": 17, "entity": "order", "id": 7170, "event": "order.status_changed", "at": "2026-09-27T07:05:00Z" },
    { "seq": 18, "entity": "stock", "id": 2211, "event": "stock.updated",        "at": "2026-09-27T07:06:12Z" }
  ],
  "next_cursor": "18", "has_more": false }
```

| Параметр | |
|---|---|
| `cursor` | `next_cursor` із попереднього виклику (цифри). Першого разу — порожньо. |
| `limit` | 1..1000, типово 500 |
| `entity` | Через кому: `order`, `product`, `price`, `stock` |

| Сутність | Право | Події |
|---|---|---|
| `order` | `orders:read` | `order.created`, `order.status_changed`, `order.paid_changed`, `order.tracking_changed`, `order.cancelled`, `order.deleted` |
| `product` | `catalog:read` | `product.saved` (форма в адмінці), `price.changed` (масова зміна цін в адмінці або запис ціни через API) |
| `price` | `prices:read` | `price.updated`, рядок на кожен варіант, записаний через цей API |
| `stock` | `stock:read` | `stock.updated`, рядок на кожен варіант, записаний через цей API |

- Зберігайте `next_cursor` лише після того, як обробили сторінку, і питайте
  знову. Порожня сторінка повертає той самий курсор.
- Поки `has_more` дорівнює `true`, викликайте одразу ще раз.
- Рядок зʼявляється приблизно через **3 секунди** після зміни. Ця коротка
  затримка гарантує, що повільніший запис, який почався раніше, не лишиться
  позаду вашого курсора.
- `order.paid_changed` покриває адмінку, цей API й онлайн-оплату через
  платіжні шлюзи. `order.tracking_changed` пише лише `PATCH /orders/{id}/ttn`
  цього API.
- Рядки стрічки живуть **30 днів**. Якщо ваш курсор старший, зробіть повну
  синхронізацію.
- Стрічка бачить **лише залишки, записані через цей API**. Залишки, змінені
  чимось, що не кидає подій (старі джоби імпорту, прямий імпорт у базу), у
  неї не потрапляють. Якщо залишки пише ще хтось, періодично робіть повний
  обхід `GET /stock`.

### Вебхуки

Вебхуки повідомляють про зміни замовлень одразу. Керуйте ними токеном із
правом `webhooks:manage`:

- `GET /webhooks` — вебхуки цього токена: `{events, webhooks}`, де `events` —
  перелік подій, на які можна підписатися;
- `POST /webhooks` (з підписом) — підписатися;
- `DELETE /webhooks/{id}` (з підписом) — вимкнути; відповідь
  `{id, active: false}`, невідомий id дає `404 NOT_FOUND`.

```json
POST /webhooks
{ "url": "https://erp.example.com/hooks/shop", "events": ["order.created", "order.status_changed"] }
```

```json
201 { "success": true, "data": { "id": 3, "secret": "…", "note": "Store the secret now; it is not shown again." }, "error": null }
```

Вимоги до адреси вебхука:

- лише `https`, порт 443 або 8443;
- хост має резолвитись лише в публічні адреси;
- не більше 5 активних вебхуків на токен.

Відхилена підписка відповідає `422` з кодом `EVENTS_REQUIRED` (подій немає або
вони невідомі), `URL_NOT_ALLOWED` (адреса порушує вимогу вище) або
`TOO_MANY_WEBHOOKS`.

Події: `order.created`, `order.status_changed`, `order.paid_changed` і
`order.tracking_changed`.

Доставка — це `POST` із таким тілом:

```json
{ "id": "34f35747902eb4681b19bb9eaea0c901", "event": "order.status_changed", "created_at": "2026-09-27T07:05:00Z",
  "data": { "order_id": 7170, "status_id": 14, "paid": false, "total": 1349, "created_at": "2026-09-27T07:02:11Z" } }
```

Вона приходить із такими заголовками:

```
X-Webhook-Event:     order.status_changed
X-Webhook-Id:        34f35747902eb4681b19bb9eaea0c901
X-Webhook-Timestamp: 1790506349
X-Webhook-Signature: hex(HMAC-SHA256(секрет_вебхука, X-Webhook-Timestamp + "." + сире тіло))
User-Agent:          CMS9-IntegrationApi/1.0
```

Правила обробки:

- **Перевіряйте підпис** над сирим тілом, перш ніж його розбирати, і
  відкидайте час, старший за 5 хвилин.
- **Відповідайте 2xx протягом 10 секунд.** Справжню роботу робіть у фоні.
  Інакше доставку буде повторено через 1 хв, 5 хв, 30 хв, 2 год, 6 год,
  12 год і 24 год.
- **Прибирайте дублі** за `X-Webhook-Id`: повтор може прийти вже після того,
  як ви обробили доставку.
- Payload несе лише id, стани, `paid` і `total`, без даних покупця. Деталі
  беріть через `GET /orders/{id}`.
- Коли токен відкликано, вимкнено чи він прострочився, його доставки в черзі
  скасовуються, і нових не буде.

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$ok  = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
    && hash_equals(hash_hmac('sha256', $ts . '.' . $raw, getenv('WEBHOOK_SECRET')), $sig);
if (! $ok) { http_response_code(401); exit; }
http_response_code(204);   // далі поставте json_decode($raw, true) у чергу на обробку
```

## Рецепти

### Відвантажувати залишки з обліку кожні N хвилин

1. На старті прочитайте `GET /warehouses` і зіставте свої склади з
   `warehouse_id` магазину.
2. Зберіть рядки `{sku | external_id | variant_id, warehouse_id, quantity}` з
   **поточних** залишків своєї системи і шліть `PUT /stock` з абсолютними
   значеннями. Абсолютні значення самовиправляються: пропущений запуск
   виправить наступний.
3. Шліть до 1000 рядків на запит, з `Idempotency-Key` на кожну порцію
   (наприклад `stock-<id запуску>-<№ порції>`).
4. Логуйте рядки зі `status: error`:
   - `AMBIGUOUS` — переведіть рядок на `variant_id`;
   - `VARIANT_NOT_FOUND` — товару в магазині немає;
   - ніколи не «лагодьте» їх записом у всі варіанти, що збіглися.
5. Тримайтеся в межах 60 записів на хвилину. На `429` чекайте `Retry-After`
   секунд.

### Відвантажувати ціни з прайсу

Те саме, що залишки, але через `PUT /prices`:

- спершу запускайте з `dry_run` і дивіться рядки, відхилені з `PRICE_GUARD`;
- `force` ставте лише для рядків, які перевірила людина;
- ціни шліть у валюті варіанта (див. `currency` у `GET /prices`).

### Вивантажувати нові замовлення в облік

- **З вебхуками:** підпишіться на `order.created` і `order.status_changed`.
  На кожну доставку беріть `GET /orders/{id}` і оновлюйте запис за `id`.
- **Без вебхуків або як страховка:** кожні кілька хвилин викликайте
  `GET /changes?entity=order&cursor=<збережений>`, далі `GET /orders/{id}` на
  кожен id, потім зберігайте `next_cursor`.
- Для першого завантаження чи повної ресинхронізації —
  `GET /orders?created_since=…` з переходом за `next_cursor`. Як страховку
  раз на годину запускайте `GET /orders?updated_since=<минулий запуск>`: він
  ловить і зміни, які модулі CRM і SMS роблять без події (див. примітку в
  розділі [Замовлення](#замовлення)).
- `customers:pii` беріть, лише якщо облікові справді потрібні контакти
  покупця.

### Повертати в магазин статус відвантаження й ТТН

- `PATCH /orders/{id}/ttn` з номером ТТН.
- `PATCH /orders/{id}/status` з `status_id` магазину. Прочитайте
  `GET /order-statuses` і узгодьте відповідність статусів із власником
  магазину.
- Кожен виклик — з `Idempotency-Key`, а статус — лише коли він справді
  змінився.

## Бриф для ШІ-агента

Вставте це в задачу разом із посиланням на цю сторінку. Бриф навмисно
англійською: так його однаково читає будь-який агент.

```text
You are integrating a script with the shop's Integration API (CMS9 module integration_api).
Contract: <shop>/integration_api/v1/openapi.yaml (needs the token) or
https://docs.ecms9.com/integration-api/openapi.yaml. Docs: https://docs.ecms9.com/users/modules/integration-api

Hard rules:
1. Base URL https://<shop>/integration_api/v1. Token in "Authorization: Bearer ik_…" (or X-Api-Key), never in the URL.
   Token and signing secret come from environment variables or a secrets file outside git. Never log or print them.
2. Every PUT/POST/PATCH/DELETE is signed: X-Timestamp (unix now) and
   X-Signature = hex HMAC-SHA256(secret, METHOD\nPATH\nQUERY\nTIMESTAMP\nsha256hex(body)).
   Serialize the body once and send exactly those bytes; build the query string once and sign that exact string.
   Use the secret as-is (hex text) for the HMAC key. Re-sign every retry with a new timestamp.
   Prove the signing with POST /ping before anything else.
3. Every write is first run with "dry_run": true and the report is shown or logged. Only then run it for real.
4. Every write carries an Idempotency-Key that is stable for the same logical operation (e.g. run id + chunk no),
   so a retry after a timeout does not apply it twice. This is mandatory for POST /stock/adjust.
5. Batches: at most 1000 rows. Handle per-row results. status=error rows are reported, not retried blindly.
   AMBIGUOUS means several variants share that sku/barcode: resolve with GET /variants and send variant_id.
   Never write to "all matches".
6. Stock is per warehouse (warehouse_id from GET /warehouses). Prefer absolute PUT /stock over deltas.
   Do not compute or write the total.
7. Prices are in the variant's own currency (field currency). Do not set "force": true by default. PRICE_GUARD rows go to a human.
8. Order status/paid changes trigger the shop's side effects (CRM, fiscal receipt, SMS). Send them only when the
   value really changed, with dry_run first while developing. Status ids come from GET /order-statuses.
9. Respect limits: 300 reads and 60 writes per minute per token. On 429 or 503 READ_ONLY wait Retry-After (or back off) and retry.
   On 5xx retry with backoff and the same Idempotency-Key.
10. Branch on error.code, not on message. 401 UNAUTHORIZED, 403 IP_NOT_ALLOWED and 403 FORBIDDEN are configuration
    problems: stop and report, do not retry in a loop (20 failures in 15 min block the IP).
11. Incremental reads: keep next_cursor from GET /changes and persist it only after the page is processed.
    For order status/payment changes use webhooks or GET /changes, plus an hourly GET /orders?updated_since as a
    safety net: date_updated moves on every change, while a few modules change orders without an event.
    Times in responses are UTC ("Z"); always send updated_since/created_since with "Z" or an offset.
12. Webhook receiver: verify X-Webhook-Signature over the raw body (HMAC of timestamp + "." + body), reject
    timestamps older than 300 s, answer 2xx within 10 s, dedupe by X-Webhook-Id, fetch details via GET /orders/{id}.
13. Do not create test orders on the production shop. Test writes with dry_run, or on one agreed test product.
14. Two identical signed requests in the same second get the same signature and the second is refused
    (SIGNATURE_REPLAYED). If that can happen, add a unique ?nonce=<random> to the query (and sign it).
15. dry_run and all_or_nothing are JSON booleans (true/false), never strings or numbers.
    Query flags (dry_run, active, paid) take 1, 0, true or false.
```

## Журнал і контроль

Екран модуля **API інтеграції** в адмінці показує:

- **Токени**: створити, змінити права й IP, ротувати, вимкнути, відкликати.
  Відкликаний токен лишається в списку, щоб журнал зберіг його назву.
- **Вебхуки**: кожен вебхук з останньою доставкою й кількістю збоїв поспіль,
  плюс кнопка «вимкнути».
- **Журнал**: кожен запит — час, токен, IP, метод і шлях, код відповіді,
  змінені рядки й тривалість. Типово зберігається 90 днів.
- **Безпека й ліміти**: вимикач «Лише читання», «Лише HTTPS», ліміти й
  необовʼязкові сповіщення в Telegram про нові токени й заблоковані IP.

Коли в магазині ввімкнено **журнал аудиту**, кожна зміна через API пишеться й
туди. Це залишки, ціни, статус замовлення, оплата й ТТН. Кожен запис містить
старе й нове значення, автора `API: <назва токена> #<id>` та IP того, хто
викликав.

## Типові проблеми

| Симптом | Причина |
|---|---|
| `401 SIGNATURE_INVALID` на кожен запис | Тіло чи рядок запиту підписано не так, як надіслано (JSON серіалізовано вдруге, рядок запиту перебудовано після підпису), у шляху бракує `/integration_api/v1`, або секрет декодовано з hex. Перевірте через `POST /ping`. |
| `401 SIGNATURE_EXPIRED` | Годинник сервера відстає чи поспішає більш ніж на 300 с; увімкніть NTP. |
| `401 SIGNATURE_REPLAYED` | Повтор надіслано зі старим підписом, або два однакові запити пішли в ту саму секунду. Підписуйте кожну спробу заново; до однакових запитів додавайте `?nonce=<випадкове>`. |
| `403 IP_NOT_ALLOWED` | Скрипт працює з адреси, якої немає в списку токена. `GET /ping` показує адресу, яку бачить магазин. Якщо там адреса CDN чи балансувальника — див. примітку про проксі в розділі [Права](#права-scopes). |
| Статус замовлення змінився, а `GET /orders?updated_since=…` його не повертає | Оновіть модулі RetailCRM, SMS-підтвердження і Partmono: до 1.1.7, 1.0.6 і 1.0.2 їхні зміни не зсували час зміни замовлення. |
| Статус замовлення змінився, а вебхука немає і в `GET /changes` порожньо | Зміну зробив модуль, що пише замовлення без події (вебхук RetailCRM, SMS-підтвердження, Partmono, прапорець оплати з SalesDrive). Вона все одно є в `GET /orders?updated_since=…`. |
| `400 INVALID_FIELD` на `dry_run` | Прапорець надіслано рядком чи числом. Шліть `true` або `false`. |
| `403 FORBIDDEN` | Токену бракує права. Попросіть власника магазину додати його. |
| `429 TOO_MANY_FAILURES` | 20 хибних токенів чи підписів за 15 хвилин з цього IP. Виправте налаштування й дочекайтеся кінця блоку. |
| Рядок каже `AMBIGUOUS` | Кілька варіантів мають однаковий артикул. Шліть `variant_id`. |
| Залишок, записаний через API, згодом повертається | Той самий склад пише ще один процес: джоба імпорту чи стара синхронізація. У кожного складу має бути рівно одне джерело правди; узгодьте його з власником магазину. |
| Вітрина показує іншу ціну, ніж `price` | `price` — у валюті варіанта, а `price_storefront` — в основній валюті за курсом магазину. |
