Перейти до основного вмісту

API інтеграції

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

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

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

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

Якщо інтеграцію пише ШІ-агент

Дайте агентові цю сторінку або її машинні копії:

Розділ Бриф для ШІ-агента наприкінці перелічує правила, яких має триматися інтеграція.

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

  1. Адмінка → Модулі → API інтеграції (/admin/integration-api). Створіть токен:

    • дайте йому назву;
    • позначте потрібні права, і не більше;
    • впишіть IP-адреси сервера, з якого працює ваш скрипт. Для будь-якого права на запис список обовʼязковий.
  2. Адмінка один раз покаже два значення: токен ik_… і секрет підпису. Скопіюйте обидва одразу й передайте розробнику захищеним каналом. Магазин зберігає лише хеш токена, а секрет не зберігає взагалі, тож показати їх удруге неможливо. Якщо загубили, ротуйте токен.

  3. Перевірте токен:

    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)

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

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

{ "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:readGET /stock, include=stock; рядки stock у стрічці
stock:writePUT /stock, POST /stock/adjust
prices:readGET /prices, include=price_types; рядки price у стрічці
prices:writePUT /prices
orders:read/orders, /order-statuses, /payment-methods, /delivery-methods; рядки order у стрічці
orders:writePATCH /orders/{id}/status, …/paid, …/ttn
customers:piiдодає до замовлень блок customer: імʼя, телефон, email, адреса, коментар
webhooks:manageGET/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 працюють із будь-яким токеном.

Магазин за CDN чи балансувальником

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

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

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

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

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

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

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

Ліміти​

ЛімітТипово
Читання300 на хвилину на токен
Запис60 на хвилину на токен
Рядків в одному пакеті1000
Тіло запиту2 МБ
Невдалих входів до блокування IP20 за фіксоване 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_FOUNDwarehouse_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-statusesorders:read[{id, name, color, is_cancel, stock_action}]
GET /payment-methodsorders:read[{id, name, …}]
GET /delivery-methodsorders:read[{id, name, …}]

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

Каталог​

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

Параметр
updated_sinceISO-8601 або unix-секунди. Лише товари, змінені відтоді.
category_id, brand_idФільтр за категорією чи брендом
active1/true або 0/false
includeСписок через кому: stock (потрібне stock:read), price_types (потрібне prices:read)
cursor, limitlimit 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_sinceISO-8601 або unix-секунди; сортування за часом зміни
created_sinceТе саме, але за часом створення; для «лише нових замовлень»
statusId статусів через кому
paid1/true або 0/false
cursor, limitlimit 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 }
Параметр
cursornext_cursor із попереднього виклику (цифри). Першого разу — порожньо.
limit1..1000, типово 500
entityЧерез кому: order, product, price, stock
СутністьПравоПодії
orderorders:readorder.created, order.status_changed, order.paid_changed, order.tracking_changed, order.cancelled, order.deleted
productcatalog:readproduct.saved (форма в адмінці), price.changed (масова зміна цін в адмінці або запис ціни через API)
priceprices:readprice.updated, рядок на кожен варіант, записаний через цей API
stockstock:readstock.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 хвилин​

  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, а статус — лише коли він справді змінився.

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

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

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_FAILURES20 хибних токенів чи підписів за 15 хвилин з цього IP. Виправте налаштування й дочекайтеся кінця блоку.
Рядок каже AMBIGUOUSКілька варіантів мають однаковий артикул. Шліть variant_id.
Залишок, записаний через API, згодом повертаєтьсяТой самий склад пише ще один процес: джоба імпорту чи стара синхронізація. У кожного складу має бути рівно одне джерело правди; узгодьте його з власником магазину.
Вітрина показує іншу ціну, ніж priceprice — у валюті варіанта, а price_storefront — в основній валюті за курсом магазину.