Integration API
The Integration API module (integration_api) gives your own scripts and
external systems a safe way to talk to the shop without the admin panel. That
covers accounting, ERP, a warehouse program, a price aggregator or a courier
service. Through it they can:
- read the catalog: products, variants, categories and brands;
- read and write stock by warehouse, as absolute quantities or as deltas;
- read and write prices: the base price, the old price and price types;
- read orders, change their status and payment flag, and set the tracking number (ТТН);
- pick up everything that changed since the last run from the changes feed, or get a webhook the moment an order changes.
It is a server-to-server API. It is not meant for a browser or a mobile app; that is what the Headless API is for.
The module is free and part of the CMS9 core since core build 5.0.5-b562.
It needs no licence and is not bought separately in the module store: the core
update brings it to every site, and it appears in Admin → Modules. Until
you create a token, the API answers every request with 401 and changes
nothing.
Give the agent this page, or these machine-readable copies:
- the article as plain Markdown:
/integration-api/integration-api.md; - the OpenAPI 3 contract:
/integration-api/openapi.yaml. Your shop serves its own copy at/integration_api/v1/openapi.yaml; it needs a token; - a working PHP client with signing and webhook verification:
/integration-api/client.php.txt.
The section Brief for an AI coding agent at the end lists the rules the integration must follow.
Quick start
-
Admin → Modules → API інтеграції (
/admin/integration-api). Create a token:- give it a name;
- tick the scopes it needs, and no more;
- list the IP addresses of the server your script runs on. The list is required for any write scope.
-
The admin shows two values once: the token
ik_…and the signing secret. Copy both right away and pass them to the developer over a secure channel. The shop stores only a hash of the token and does not store the secret at all, so neither can be shown again. If you lose them, rotate the token. -
Check the token:
curl -s https://your-shop/integration_api/v1/ping \-H "Authorization: Bearer $INTAPI_TOKEN"The answer contains the token name, its scopes and
your_ip, the address the shop sees. Put that address in the token's IP list. -
Check the signing code with
POST /ping, as shown in Signing. Every write must be signed. -
Run each write with
"dry_run": truefirst. It validates the request and shows what would change without writing anything.
Basics
| Base URL | https://your-shop/integration_api/v1 |
| Transport | HTTPS only. Plain HTTP answers 403 HTTPS_REQUIRED. |
| Format | JSON in and out, UTF-8. Send Content-Type: application/json with a body. |
| Auth | Authorization: Bearer ik_…. If a proxy strips Authorization, use X-Api-Key: ik_…. Never in the query string. |
| Language of names | ?locale=ua or ?locale=en. The default is the shop's main language. |
| Time | Responses give UTC in ISO-8601 with Z, e.g. 2026-09-27T07:02:11Z. Filters accept ISO-8601 or unix seconds. A time without an offset is read in the shop's timezone, so always send Z or an offset. |
| Booleans in the query | active, paid and dry_run take 1, 0, true or false. Any other value is refused with 400 INVALID_FIELD. |
| Identifiers | sku, barcode and external_id match case-insensitively, with spaces at the edges ignored. |
| Contract | GET /openapi.yaml (OpenAPI 3.0) |
Response envelope
Every response, including errors and unknown paths, has the same shape:
{ "success": true, "data": { … }, "error": null }
{ "success": false, "data": null, "error": { "code": "FORBIDDEN", "message": "…", "details": { … } } }
Branch on error.code. The message is for people and may change.
Scopes
| Scope | Gives |
|---|---|
catalog:read | /categories, /brands, /products, /variants; product rows of the changes feed |
stock:read | GET /stock, include=stock; stock rows of the feed |
stock:write | PUT /stock, POST /stock/adjust |
prices:read | GET /prices, include=price_types; price rows of the feed |
prices:write | PUT /prices |
orders:read | /orders, /order-statuses, /payment-methods, /delivery-methods; order rows of the feed |
orders:write | PATCH /orders/{id}/status, …/paid, …/ttn |
customers:pii | adds the customer block (name, phone, email, address, comment) to orders |
webhooks:manage | GET/POST /webhooks, DELETE /webhooks/{id} |
The write scopes (stock:write, prices:write, orders:write and
webhooks:manage) need an IP allow list. A read-only token may have one too;
the list is enforced whenever it is not empty. An entry is one address or a
range, no wider than /16 for IPv4 or /32 for IPv6. A token can also have an
expiry date.
/ping, /openapi.yaml, /warehouses, /currencies, /price-types and
/changes work with any token.
The IP check uses the address the shop sees. If the shop sits behind a CDN or
a load balancer, the shop's administrator must list the proxy in the CMS
setting app.proxyIPs, with the header the proxy overwrites (for example
X-Forwarded-For from the shop's own balancer). Otherwise every request looks
like it comes from the proxy. GET /ping shows the address the shop sees.
Give each system its own token. The journal then shows who did what, and one system can be cut off without touching the others.
Order of checks and error codes
The shop checks a request in this order. The first failing check answers.
| # | Check | HTTP | error.code |
|---|---|---|---|
| 1 | Module switched off | 404 | NOT_FOUND |
| 2 | Not HTTPS | 403 | HTTPS_REQUIRED |
| 3 | IP blocked after repeated auth failures | 429 | TOO_MANY_FAILURES (+ Retry-After) |
| 4 | Token missing, wrong, disabled, revoked or expired | 401 | UNAUTHORIZED (all look the same on purpose) |
| 5 | IP not in the token's list | 403 | IP_NOT_ALLOWED |
| 6 | Scope missing | 403 | FORBIDDEN |
| 7 | Writes only: shop in read-only mode | 503 | READ_ONLY |
| Writes only: body too large (2 MB by default) | 413 | BODY_TOO_LARGE | |
| Writes only: signature | 401 | SIGNATURE_MISSING, SIGNATURE_EXPIRED, SIGNATURE_INVALID, SIGNATURE_REPLAYED | |
| 8 | Rate limit | 429 | RATE_LIMITED (+ Retry-After) |
After those checks come errors about the request itself:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_JSON | Body is not a JSON object |
| 400 | UNKNOWN_FIELD | Body has a field the endpoint does not know. Typos are not ignored. |
| 400 | INVALID_FIELD | Query parameter or field has the wrong type or format, including a dry_run or all_or_nothing that is not a boolean |
| 400 | INVALID_CURSOR | Cursor was not taken from next_cursor |
| 400 | ITEMS_REQUIRED | Batch without items |
| 400 | INVALID_ROW | A batch row is not an object |
| 400 | BAD_IDEMPOTENCY_KEY | Idempotency-Key does not match ^[A-Za-z0-9_.:-]{8,64}$ |
| 404 | NOT_FOUND | No such object or endpoint (also GET /orders/{id} for an unknown order) |
| 404 | ORDER_NOT_FOUND | The order of a PATCH /orders/{id}/… does not exist |
| 409 | IDEMPOTENCY_CONFLICT | Same Idempotency-Key, different request |
| 409 | IDEMPOTENCY_IN_PROGRESS | Same key is still running (Retry-After: 2) |
| 413 | BATCH_TOO_LARGE | More rows than allowed (1000 by default) |
| 422 | VALIDATION_FAILED | No row of a batch could be applied, or all_or_nothing hit a bad row. Per-row errors are in details.rows. |
| 422 | endpoint-specific | For example INVALID_TTN or TRACKING_OWNED_BY_MODULE; see each endpoint |
| 500 | INTERNAL_ERROR | Server fault. Nothing was applied; retry later. |
Limits
| Limit | Default |
|---|---|
| Reads | 300 per minute per token |
| Writes | 60 per minute per token |
| Rows in one batch | 1000 |
| Request body | 2 MB |
| Auth failures before an IP is blocked | 20 in a fixed 15-minute window, then blocked until that window ends |
| Signature window | ±300 s (the shop owner can change it; at least 30 s) |
The shop owner can change these under Безпека й ліміти in the module's admin screen.
Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset. The limits count per calendar minute, and
X-RateLimit-Reset is the number of seconds until the next minute starts.
On 429, wait Retry-After seconds.
Signing
Every write must be signed: PUT, POST, PATCH and DELETE.
Signing reads is optional. The signature proves the request came from
whoever holds the secret and that nobody changed it on the way.
Headers:
X-Timestamp: <unix seconds, now>
X-Signature: <hex HMAC-SHA256 of the canonical string, keyed with the signing secret>
The canonical string is five lines joined with \n, with no trailing newline:
METHOD upper case: PUT
PATH full path from the first slash: /integration_api/v1/stock
QUERY query string exactly as sent, without "?"; empty line if none
TIMESTAMP the same value as X-Timestamp
SHA256(BODY) lowercase hex of SHA-256 over the raw body bytes; for an empty body, SHA-256 of ""
Rules that trip people up:
- Sign the exact bytes you send. Serialize the body once, sign that string, and send the same string. If you let the HTTP library re-serialize a dict or object, the hash changes.
- Sign the query string exactly as it goes on the wire. Build it once,
put it in the URL, and sign the same string. The shop does not sort or
re-encode it:
b=2&a=1anda=1&b=2are different strings with different signatures. - The secret is the hex string the admin showed. It is used as the HMAC key as-is; do not hex-decode it.
- The timestamp must be within ±300 s of the shop's clock (the default), so keep NTP on.
- A signature is accepted once. A retry must be signed again with a new
timestamp, which gives a new signature. To avoid applying a retried write
twice, use
Idempotency-Key(see below). - Two identical requests in the same second have the same signature, so the
second one gets
401 SIGNATURE_REPLAYED. If your script can send the same write twice within a second, add a unique query parameter such as?nonce=<random>(it is signed like any other part of the query) or wait for the next second. - Rotating the token in the admin also changes the secret.
POST /ping with any body checks your signing without touching data. It works
with any scope, and its signature failures do not count toward the IP block,
so it is safe to debug with:
{ "success": true, "data": { "ok": true, "signature": "valid", "body_sha256": "…" }, "error": null }
If the signature is wrong, body_sha256 is not returned. Compare your own body
hash and canonical string with the rules above.
PHP
function intapi_call(string $method, string $path, string $query = '', ?array $body = null, ?string $idemKey = null): array
{
$base = getenv('INTAPI_BASE'); // https://your-shop/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://your-shop/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
# Pass the ready URL and the raw bytes, not params=/json=: requests would re-encode them.
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://your-shop/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://your-shop$P" -H "Authorization: Bearer $INTAPI_TOKEN" \
-H "X-Timestamp: $TS" -H "X-Signature: $SIG" -H 'Content-Type: application/json' -d "$BODY"
Safe writing
dry_run
Pass "dry_run": true in the body, or ?dry_run=1 in the query, on any
write. In the body it must be a JSON boolean; in the query 1, 0, true,
false or empty. Anything else, such as "yes" or "1" in the body, is refused
with 400 INVALID_FIELD, so a typo never turns a trial into a live write. The shop runs every check and returns the same per-row report. Rows say
would_update instead of updated, and applied is false. Nothing is
written, no event fires, and no webhook is sent. Run each new script with
dry_run until the report looks right.
Idempotency-Key
For writes that must not happen twice (above all POST /stock/adjust, where
two -2 deltas take away 4), send a unique key per logical operation:
Idempotency-Key: stock-2026-09-27T10:00-batch-17
- The key is 8–64 characters from
A–Z a–z 0–9 _ . : -, and it is scoped to your token. - A retry with the same key and the same request does not apply the write
again. It returns the stored answer with the header
Idempotent-Replayed: true. - The same key with a different request answers
409 IDEMPOTENCY_CONFLICT. - If the first request is still running, you get
409 IDEMPOTENCY_IN_PROGRESS. WaitRetry-Afterseconds and retry. - Keys are kept for 24 hours. Answers to
dry_runrequests and5xxerrors are not stored, so those can be retried with the same key.
A retry must still be signed again with a fresh timestamp. The key, not the signature, is what makes it safe.
Batches
PUT /stock, POST /stock/adjust and PUT /prices take a batch:
{
"items": [ { "sku": "RC-1234", "warehouse_id": 1, "quantity": 12 }, … ],
"dry_run": false,
"all_or_nothing": false
}
Each row names exactly one variant by one of these fields:
variant_id: the shop's id. This one is always unambiguous.sku: the variant's article number, as shown in the product form.barcode.external_id: the id from your accounting system.
If a sku, barcode or external_id matches more than one variant, the row
is refused with AMBIGUOUS and nothing is written to any of them. For such
rows use variant_id: look it up with GET /variants?sku=…, which returns
every match.
By default, good rows are applied and bad rows are reported. With
"all_or_nothing": true, a single bad row rejects the whole batch with
422 VALIDATION_FAILED. The same 422 comes back when no row at all could be
applied.
The good rows of a batch are written in one database transaction. If the
request fails half way (timeout, server error), nothing of it is applied, and a
retry with the same Idempotency-Key cannot apply half a batch twice.
The result is a report per row:
{
"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 is the row's position in your items. A row's status is one of
updated, unchanged, would_update (with dry_run), skipped (a valid row
not applied because all_or_nothing failed) or error.
Row error codes:
| Code | Meaning |
|---|---|
IDENTIFIER_REQUIRED | The row has no identifier, or more than one |
VARIANT_NOT_FOUND | No variant with that identifier |
AMBIGUOUS | Several variants match; send variant_id |
WAREHOUSE_NOT_FOUND | warehouse_id is missing or unknown |
OUT_OF_RANGE | Quantity is outside 0..10 000 000, the delta is 0, or the result would be below zero |
DUPLICATE_ROW | Same variant and warehouse (or price type) twice in one batch |
PRICE_GUARD | Price change is over 50 %, or the price is 0, without "force": true |
PRICE_TYPE_NOT_FOUND | Unknown price_type_id |
INVALID_FIELD, UNKNOWN_FIELD | A field with the wrong type, or a field the endpoint does not know |
Read-only switch
The shop owner can switch the API to read-only with one checkbox in the
admin. Every write then answers 503 READ_ONLY and reads keep working. Treat
503 READ_ONLY as "stop and retry later", not as a bug.
Endpoint reference
All paths are relative to https://your-shop/integration_api/v1.
Service and dictionaries
| Method and path | Scope | Returns |
|---|---|---|
GET /ping | any | {ok, version, token{name, prefix, scopes, expires_at}, your_ip, server_time} |
POST /ping | any, signed | {ok, signature: "valid", body_sha256} |
GET /openapi.yaml | any | OpenAPI contract, with this shop's base URL filled in |
GET /warehouses | any | [{id, name, address, active, is_default, show_on_storefront, position}] |
GET /currencies | any | [{id, code, name, symbol, main, is_default, rate}] |
GET /price-types | any | [{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, …}] |
Read the dictionaries once at the start of a run. Status, warehouse and price type ids are what the other endpoints take.
Catalog
GET /products (catalog:read) returns products with their variants,
oldest change first.
| Parameter | |
|---|---|
updated_since | ISO-8601 or unix seconds. Only products changed since then. |
category_id, brand_id | Filter by category or brand |
active | 1/true or 0/false |
include | Comma list: stock (needs stock:read), price_types (needs prices:read) |
cursor, limit | limit is 1..500, default 100. Follow next_cursor until it is 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 kg", "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": "…" }
warehousescomes only withinclude=stock, andprice_typesonly withinclude=price_types.priceis in the variant's owncurrency, as typed in the product form.price_storefrontis what the storefront shows, in the shop's main currency.stockis the total over all warehouses.
GET /products/{id} returns one product in the same shape; include
works here too.
GET /variants looks up variants by one kind of identifier:
ids=1,2,3, sku=A,B, barcode=… or external_id=…. Values are
comma-separated, up to 1000. Every match is returned, so duplicate SKUs show
up here. Use this to map your codes to variant_id before writing.
GET /categories returns the category tree as a flat list, and
GET /brands returns brands. Both need catalog:read.
Stock
The shop keeps stock per warehouse. The variant's total (stock) is
always the sum of its warehouse rows, and the shop recalculates it after every
write. Write to warehouses, never to the total.
GET /stock (stock:read) takes ids, sku, barcode or
external_id (comma lists). Without them it walks every variant with cursor
and limit (1..1000, default 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, signed) sets absolute quantities:
{ "items": [ { "sku": "RC-1234", "warehouse_id": 1, "quantity": 12 } ], "dry_run": true }
POST /stock/adjust (stock:write, signed) changes quantities by a
delta. Rows are locked while they are written, so two scripts adjusting the
same warehouse at once cannot lose an update. A result below zero is refused
with OUT_OF_RANGE and leaves the row unchanged; if another write got there
first, the message is Stock changed meanwhile. Always send an
Idempotency-Key.
{ "items": [ { "variant_id": 2211, "warehouse_id": 1, "delta": -2 } ] }
After a write, the shop does the same things as after a save in the admin:
- product feeds are refreshed;
- the search index is updated;
- the page cache is purged;
- a row goes into the changes feed.
Prices
GET /prices (prices:read) takes ids, sku, barcode or
external_id. For each variant it returns
{variant_id, product_id, sku, price, old_price, currency, price_storefront, price_types[]}.
PUT /prices (prices:write, signed):
{ "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 }
] }
priceis in the variant's own currency, the one shown in the product form and returned ascurrency. For a variant priced in USD, send USD; the shop recalculates the storefront price at its rate.- Without
price_type_idthe row sets the base price, and optionallyold_price. Withprice_type_idit sets the price of that price type;old_priceis not allowed there. - Prices are 0..100 000 000 with up to 2 decimals.
- Price guard. A change of more than 50 %, or a price of 0, is refused
with
PRICE_GUARDunless the row has"force": true. This protects the shop from a unit mix-up (kopecks vs hryvnias) or an empty column in a price list. Do not setforceon every row by default.
Orders
GET /orders (orders:read):
| Parameter | |
|---|---|
updated_since | ISO-8601 or unix seconds; sorted by update time |
created_since | Same, but by creation time; use it for "new orders only" |
status | Comma list of status ids |
paid | 1/true or 0/false |
cursor, limit | limit is 1..200, default 50. Follow next_cursor. The cursor belongs to the sort order: with created_since the list is sorted by creation time, otherwise by update time. Do not mix cursors between the two. |
updated_since, the changes feed and webhooksupdated_since follows the order's own update time, and every status or
payment change moves it: in the admin, by a payment gateway, from the API, and
by the CRM and SMS modules (RetailCRM 1.1.7+, SMS confirmation 1.0.6+,
Partmono 1.0.2+). The changes feed and webhooks
report changes as they happen, but only those that pass through the shop's
order events: the admin, payment gateways, Nova Poshta auto-TTN, SalesDrive
status updates and this API. A status set straight by the RetailCRM webhook or
by SMS confirmation, or a paid flag set by Partmono or SalesDrive, shows up only
in updated_since. So follow changes with webhooks or the feed, and run
GET /orders?updated_since=… from time to time as a safety net.
An order:
{
"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 kg",
"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 is present only if the token has customers:pii. tracking is
null when there is no tracking number.
GET /orders/{id} returns one order in the same shape.
PATCH /orders/{id}/status (orders:write, signed):
{ "status_id": 14, "comment": "Відвантажено зі складу", "dry_run": true }
The answer is {order_id, dry_run, changed, old_status, new_status, paid}.
An unknown status_id gives 422 STATUS_NOT_FOUND, and an unknown order gives
404 ORDER_NOT_FOUND.
It does exactly what a status change in the admin does, with every side effect
the shop has set up for it: CRM sync, a fiscal receipt, SMS and Telegram
notices to the buyer, and stock actions tied to the status (return to
warehouse on cancel, for example). Run it with dry_run first, and never
send a status "just to be sure".
PATCH /orders/{id}/paid (orders:write, signed) takes
{"paid": true, "dry_run": false}. It answers the same way. Marking an order
paid can trigger a fiscal receipt if the shop fiscalizes payments, so
coordinate with the shop owner.
PATCH /orders/{id}/ttn (orders:write, signed) sets the tracking number:
{ "number": "20450000000001", "carrier": "", "dry_run": false }
- Nova Poshta shipments are orders whose delivery method is Nova Poshta, or
that already have an NP waybill. For these:
- the number goes into the Nova Poshta module's waybill;
- it must be 14 digits, otherwise the answer is
422 INVALID_TTN; carrieris ignored;- after that the module tracks the parcel and may move the order status by its status map, just as for a waybill made in the admin;
- clearing the number is refused with
422 TRACKING_CLEAR_NOT_SUPPORTED. Delete the waybill in the admin instead.
- Another carrier module owns the shipment:
422 TRACKING_OWNED_BY_MODULE. - Any other order: the number goes into the order's manual tracking field
together with
carrier. An emptynumberclears it.
Changes feed
GET /changes works with any token, but you only get the entities the
token can read. It returns what changed after your cursor, oldest first, as
ids only:
{ "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 }
| Parameter | |
|---|---|
cursor | The next_cursor from your previous call (digits). Leave it empty the first time. |
limit | 1..1000, default 500 |
entity | Comma list: order, product, price, stock |
| Entity | Scope | Events |
|---|---|---|
order | orders:read | order.created, order.status_changed, order.paid_changed, order.tracking_changed, order.cancelled, order.deleted |
product | catalog:read | product.saved (admin form), price.changed (mass price edit in the admin, or a price write through the API) |
price | prices:read | price.updated, one row per variant written through this API |
stock | stock:read | stock.updated, one row per variant written through this API |
- Save
next_cursoronly after you have processed the page, then ask again. An empty page returns the same cursor. - While
has_moreistrue, call again straight away. - A row appears about 3 seconds after the change. The short delay makes sure a slower write that started earlier is never skipped behind your cursor.
order.paid_changedcovers the admin, this API and online payment gateways.order.tracking_changedis written only byPATCH /orders/{id}/ttnof this API.- Feed rows are kept for 30 days. If your cursor is older than that, do a full resync.
- The feed shows stock written through this API only. Stock changed by
something that fires no event, such as legacy import jobs or direct database
imports, does not appear. If anything else writes stock, also do a periodic
full
GET /stockwalk.
Webhooks
Webhooks tell you about order changes as they happen. Manage them with a token
that has webhooks:manage:
GET /webhookslists this token's webhooks:{events, webhooks}, whereeventsis the list of events you can subscribe to;POST /webhooks(signed) subscribes;DELETE /webhooks/{id}(signed) switches one off and answers{id, active: false}; an unknown id gives404 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 }
Rules for the webhook URL:
- it must be
https, on port 443 or 8443; - its host must resolve to public addresses only;
- a token can have at most 5 active webhooks.
A refused subscription answers 422 with EVENTS_REQUIRED (no or unknown
events), URL_NOT_ALLOWED (the URL breaks a rule above) or
TOO_MANY_WEBHOOKS.
The events are order.created, order.status_changed, order.paid_changed
and order.tracking_changed.
A delivery is a POST with this body:
{ "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" } }
It comes with these headers:
X-Webhook-Event: order.status_changed
X-Webhook-Id: 34f35747902eb4681b19bb9eaea0c901
X-Webhook-Timestamp: 1790506349
X-Webhook-Signature: hex(HMAC-SHA256(webhook_secret, X-Webhook-Timestamp + "." + raw body))
User-Agent: CMS9-IntegrationApi/1.0
Handling rules:
- Verify the signature over the raw body before you parse it, and reject timestamps older than 5 minutes.
- Answer 2xx within 10 seconds. Do the real work in the background. Otherwise the delivery is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h.
- Deduplicate by
X-Webhook-Id, since a retry can arrive after you have already processed the delivery. - The payload carries ids, states,
paidandtotalonly, with no buyer data. FetchGET /orders/{id}for the details. - When the token is revoked, disabled or expires, its queued deliveries are cancelled and no more are sent.
$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); // then queue json_decode($raw, true) for processing
Recipes
Push stock from accounting every N minutes
- At startup, read
GET /warehousesand map your warehouses to the shop'swarehouse_id. - Build rows
{sku | external_id | variant_id, warehouse_id, quantity}from your system's current balances, usingPUT /stockwith absolute values. Absolute values heal themselves: a missed run is fixed by the next one. - Send up to 1000 rows per request, with an
Idempotency-Keyper chunk (for examplestock-<run id>-<chunk no>). - Log the rows with
status: error:AMBIGUOUS: switch that row tovariant_id;VARIANT_NOT_FOUND: the product is missing in the shop;- never "fix" either of them by writing to all matching variants.
- Stay under 60 writes per minute. On
429, sleepRetry-Afterseconds.
Push prices from a price list
Same as stock, with PUT /prices:
- run with
dry_runfirst and look at the rows refused withPRICE_GUARD; - set
forceonly for rows a person has checked; - send prices in the variant's currency (check
currencyinGET /prices).
Export new orders to accounting
- With webhooks: subscribe to
order.createdandorder.status_changed. On each delivery, fetchGET /orders/{id}and upsert it byid. - Without webhooks, or as a safety net: every few minutes, call
GET /changes?entity=order&cursor=<saved>, thenGET /orders/{id}for each id, then savenext_cursor. - For a first load or a full resync, use
GET /orders?created_since=…and follownext_cursor. As a safety net, runGET /orders?updated_since=<last run>every hour or so: it also catches changes that CRM and SMS modules make without an event (see the note under Orders). - Use
customers:piionly if accounting really needs the buyer's contacts.
Send shipment status and tracking back
PATCH /orders/{id}/ttnwith the tracking number.PATCH /orders/{id}/statuswith the shop'sstatus_id. ReadGET /order-statusesand agree the mapping with the shop owner.- Send an
Idempotency-Keywith each call, and a status only when it really changed.
Brief for an AI coding agent
Paste this into the task together with the link to this page.
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.
Journal and control
The module's admin screen, API інтеграції, shows:
- Tokens: create, edit scopes and IPs, rotate, disable, revoke. A revoked token stays in the list so the journal keeps its name.
- Webhooks: every webhook with its last delivery and the number of failures in a row, plus a button to switch one off.
- Journal: every request with its time, token, IP, method and path, status code, rows changed and duration. It is kept for 90 days by default.
- Security and limits: the read-only switch, HTTPS only, the limits and optional Telegram alerts about new tokens and blocked IPs.
When the shop's audit log is on, every change made through the API is also
recorded there. That covers stock, prices, order status, payment and tracking.
Each record has the old and new value, the author API: <token name> #<id>
and the caller's IP.
Common problems
| Symptom | Cause |
|---|---|
401 SIGNATURE_INVALID on every write | The body or query was signed differently from what was sent (re-serialized JSON, a query rebuilt after signing), the path lacks /integration_api/v1, or the secret was hex-decoded. Check with POST /ping. |
401 SIGNATURE_EXPIRED | The server clock is off by more than 300 s; turn on NTP. |
401 SIGNATURE_REPLAYED | A retry reused the old signature, or two identical requests went out in the same second. Sign every attempt again; add ?nonce=<random> to identical requests. |
403 IP_NOT_ALLOWED | The script runs from an address that is not in the token's list. GET /ping shows the address the shop sees. If it shows a CDN or balancer address, see the note on proxies under Scopes. |
An order's status changed, but GET /orders?updated_since=… does not return it | Update the RetailCRM, SMS confirmation and Partmono modules: before 1.1.7, 1.0.6 and 1.0.2 their changes did not move the order's update time. |
An order's status changed, but there is no webhook and nothing in GET /changes | The change came from a module that writes the order without an event (RetailCRM webhook, SMS confirmation, Partmono, SalesDrive payment flag). It still shows up in GET /orders?updated_since=…. |
400 INVALID_FIELD on dry_run | The flag was sent as a string or number. Send true or false. |
403 FORBIDDEN | The token lacks the scope. Ask the shop owner to add it. |
429 TOO_MANY_FAILURES | 20 bad tokens or signatures in 15 minutes from this IP. Fix the configuration, then wait for the block to end. |
A row says AMBIGUOUS | Several variants share the SKU. Use variant_id. |
| Stock written through the API changes back after a while | Another process, such as an import job or a legacy sync, also writes that warehouse. Each warehouse needs exactly one source of truth; agree it with the shop owner. |
The storefront shows a different price from price | price is in the variant's currency and price_storefront is in the main currency, at the shop's rate. |