API інтеграції
Модуль API інтеграції (integration_api) дає вашим скриптам і зовнішнім
системам безпечний спосіб працювати з магазином без адмінки. Це може бути
облік, ERP, складська програма, агрегатор цін чи служба доставки. Через API
вони можуть:
- читати каталог: товари, варіанти, категорії й бренди;
- читати й писати залишки по складах, абсолютним числом або дельтою;
- читати й писати ціни: базову, стару й типи цін;
- читати замовлення, змінювати їхній статус і ознаку оплати, ставити ТТН;
- забирати все, що змінилось з минулого запуску, зі стрічки змін, або одразу отримувати вебхук, щойно змінилось замовлення.
Це API «сервер — сервер». Він не для браузера чи мобільного застосунку: для цього є Headless API.
Модуль безкоштовний і входить у ядро CMS9 з білда ядра 5.0.5-b562.
Ліцензія не потрібна, окремо в магазині модулів його не купують: він приходить
на кожен сайт з оновленням ядра й з'являється в Адмінка → Модулі. Поки ви
не створили токен, API на будь-який запит відповідає 401 і нічого не змінює.
Дайте агентові цю сторінку або її машинні копії:
- стаття як звичайний Markdown:
/integration-api/integration-api.uk.md; англійською:/integration-api/integration-api.md; - контракт OpenAPI 3:
/integration-api/openapi.yaml. Ваш магазин віддає свою копію за адресою/integration_api/v1/openapi.yaml, з токеном; - робочий клієнт на PHP з підписом і перевіркою вебхука:
/integration-api/client.php.txt.
Розділ Бриф для ШІ-агента наприкінці перелічує правила, яких має триматися інтеграція.
Швидкий старт
-
Адмінка → Модулі → API інтеграції (
/admin/integration-api). Створіть токен:- дайте йому назву;
- позначте потрібні права, і не більше;
- впишіть IP-адреси сервера, з якого працює ваш скрипт. Для будь-якого права на запис список обовʼязковий.
-
Адмінка один раз покаже два значення: токен
ik_…і секрет підпису. Скопіюйте обидва одразу й передайте розробнику захищеним каналом. Магазин зберігає лише хеш токена, а секрет не зберігає взагалі, тож показати їх удруге неможливо. Якщо загубили, ротуйте токен. -
Перевірте токен:
curl -s https://ваш-магазин/integration_api/v1/ping \-H "Authorization: Bearer $INTAPI_TOKEN"У відповіді будуть назва токена, його права і
your_ip, тобто адреса, яку бачить магазин. Саме її впишіть у список IP токена. -
Перевірте код підпису через
POST /ping, як показано в розділі Підпис. Кожен запис має бути підписаний. -
Кожен запис спершу запускайте з
"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) |
Конверт відповіді
Кожна відповідь має однакову форму, зокрема помилки й невідомі шляхи:
{ "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 працюють із будь-яким токеном.
Перевірка 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,
тож на ньому безпечно налагоджувати:
{ "success": true, "data": { "ok": true, "signature": "valid", "body_sha256": "…" }, "error": null }
Якщо підпис хибний, body_sha256 не повертається. Звірте свій хеш тіла й
канонічний рядок із правилами вище.
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
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+
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)
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 приймають пакет:
{
"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 не може застосувати пів пакета двічі.
Результат — звіт по кожному рядку:
{
"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. |
{ "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).
{ "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, з підписом) ставить абсолютні кількості:
{ "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.
{ "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, з підписом):
{ "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 список іде за часом створення, інакше — за часом зміни. Не змішуйте курсори між ними. |
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=… час від часу
запускайте як страховку.
Замовлення:
{
"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, з підписом):
{ "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.
Вона робить рівно те саме, що зміна статусу в адмінці, з усіма наслідками,
які налаштовано в магазині: синхронізація з CRM, фіскальний чек, SMS і
Telegram покупцеві, складські дії статусу (наприклад, повернення на склад при
скасуванні). Спершу запускайте з dry_run і ніколи не шліть статус «про всяк
випадок».
PATCH /orders/{id}/paid (orders:write, з підписом) приймає
{"paid": true, "dry_run": false} і відповідає так само. Позначка «оплачено»
може запустити фіскальний чек, якщо магазин фіскалізує оплати, тож узгодьте
це з власником магазину.
PATCH /orders/{id}/ttn (orders:write, з підписом) ставить ТТН:
{ "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:
{ "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.
POST /webhooks
{ "url": "https://erp.example.com/hooks/shop", "events": ["order.created", "order.status_changed"] }
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 із таким тілом:
{ "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}. - Коли токен відкликано, вимкнено чи він прострочився, його доставки в черзі скасовуються, і нових не буде.
$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 хвилин
- На старті прочитайте
GET /warehousesі зіставте свої склади зwarehouse_idмагазину. - Зберіть рядки
{sku | external_id | variant_id, warehouse_id, quantity}з поточних залишків своєї системи і шлітьPUT /stockз абсолютними значеннями. Абсолютні значення самовиправляються: пропущений запуск виправить наступний. - Шліть до 1000 рядків на запит, з
Idempotency-Keyна кожну порцію (наприкладstock-<id запуску>-<№ порції>). - Логуйте рядки зі
status: error:AMBIGUOUS— переведіть рядок наvariant_id;VARIANT_NOT_FOUND— товару в магазині немає;- ніколи не «лагодьте» їх записом у всі варіанти, що збіглися.
- Тримайтеся в межах 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, а статус — лише коли він справді змінився.
Бриф для ШІ-агента
Вставте це в задачу разом із посиланням на цю сторінку. Бриф навмисно англійською: так його однаково читає будь-який агент.
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 чи балансувальника — див. примітку про проксі в розділі Права. |
Статус замовлення змінився, а 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 — в основній валюті за курсом магазину. |