Skip to main content

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.

Handing this to an AI coding agent

Give the agent this page, or these machine-readable copies:

The section Brief for an AI coding agent at the end lists the rules the integration must follow.

Quick start​

  1. 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.
  2. 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.

  3. 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.

  4. Check the signing code with POST /ping, as shown in Signing. Every write must be signed.

  5. Run each write with "dry_run": true first. It validates the request and shows what would change without writing anything.

Basics​

Base URLhttps://your-shop/integration_api/v1
TransportHTTPS only. Plain HTTP answers 403 HTTPS_REQUIRED.
FormatJSON in and out, UTF-8. Send Content-Type: application/json with a body.
AuthAuthorization: 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.
TimeResponses 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 queryactive, paid and dry_run take 1, 0, true or false. Any other value is refused with 400 INVALID_FIELD.
Identifierssku, barcode and external_id match case-insensitively, with spaces at the edges ignored.
ContractGET /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​

ScopeGives
catalog:read/categories, /brands, /products, /variants; product rows of the changes feed
stock:readGET /stock, include=stock; stock rows of the feed
stock:writePUT /stock, POST /stock/adjust
prices:readGET /prices, include=price_types; price rows of the feed
prices:writePUT /prices
orders:read/orders, /order-statuses, /payment-methods, /delivery-methods; order rows of the feed
orders:writePATCH /orders/{id}/status, …/paid, …/ttn
customers:piiadds the customer block (name, phone, email, address, comment) to orders
webhooks:manageGET/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.

Shop behind a CDN or load balancer

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.

#CheckHTTPerror.code
1Module switched off404NOT_FOUND
2Not HTTPS403HTTPS_REQUIRED
3IP blocked after repeated auth failures429TOO_MANY_FAILURES (+ Retry-After)
4Token missing, wrong, disabled, revoked or expired401UNAUTHORIZED (all look the same on purpose)
5IP not in the token's list403IP_NOT_ALLOWED
6Scope missing403FORBIDDEN
7Writes only: shop in read-only mode503READ_ONLY
Writes only: body too large (2 MB by default)413BODY_TOO_LARGE
Writes only: signature401SIGNATURE_MISSING, SIGNATURE_EXPIRED, SIGNATURE_INVALID, SIGNATURE_REPLAYED
8Rate limit429RATE_LIMITED (+ Retry-After)

After those checks come errors about the request itself:

HTTPCodeMeaning
400INVALID_JSONBody is not a JSON object
400UNKNOWN_FIELDBody has a field the endpoint does not know. Typos are not ignored.
400INVALID_FIELDQuery parameter or field has the wrong type or format, including a dry_run or all_or_nothing that is not a boolean
400INVALID_CURSORCursor was not taken from next_cursor
400ITEMS_REQUIREDBatch without items
400INVALID_ROWA batch row is not an object
400BAD_IDEMPOTENCY_KEYIdempotency-Key does not match ^[A-Za-z0-9_.:-]{8,64}$
404NOT_FOUNDNo such object or endpoint (also GET /orders/{id} for an unknown order)
404ORDER_NOT_FOUNDThe order of a PATCH /orders/{id}/… does not exist
409IDEMPOTENCY_CONFLICTSame Idempotency-Key, different request
409IDEMPOTENCY_IN_PROGRESSSame key is still running (Retry-After: 2)
413BATCH_TOO_LARGEMore rows than allowed (1000 by default)
422VALIDATION_FAILEDNo row of a batch could be applied, or all_or_nothing hit a bad row. Per-row errors are in details.rows.
422endpoint-specificFor example INVALID_TTN or TRACKING_OWNED_BY_MODULE; see each endpoint
500INTERNAL_ERRORServer fault. Nothing was applied; retry later.

Limits​

LimitDefault
Reads300 per minute per token
Writes60 per minute per token
Rows in one batch1000
Request body2 MB
Auth failures before an IP is blocked20 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=1 and a=1&b=2 are 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. Wait Retry-After seconds and retry.
  • Keys are kept for 24 hours. Answers to dry_run requests and 5xx errors 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:

CodeMeaning
IDENTIFIER_REQUIREDThe row has no identifier, or more than one
VARIANT_NOT_FOUNDNo variant with that identifier
AMBIGUOUSSeveral variants match; send variant_id
WAREHOUSE_NOT_FOUNDwarehouse_id is missing or unknown
OUT_OF_RANGEQuantity is outside 0..10 000 000, the delta is 0, or the result would be below zero
DUPLICATE_ROWSame variant and warehouse (or price type) twice in one batch
PRICE_GUARDPrice change is over 50 %, or the price is 0, without "force": true
PRICE_TYPE_NOT_FOUNDUnknown price_type_id
INVALID_FIELD, UNKNOWN_FIELDA 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 pathScopeReturns
GET /pingany{ok, version, token{name, prefix, scopes, expires_at}, your_ip, server_time}
POST /pingany, signed{ok, signature: "valid", body_sha256}
GET /openapi.yamlanyOpenAPI contract, with this shop's base URL filled in
GET /warehousesany[{id, name, address, active, is_default, show_on_storefront, position}]
GET /currenciesany[{id, code, name, symbol, main, is_default, rate}]
GET /price-typesany[{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, …}]

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_sinceISO-8601 or unix seconds. Only products changed since then.
category_id, brand_idFilter by category or brand
active1/true or 0/false
includeComma list: stock (needs stock:read), price_types (needs prices:read)
cursor, limitlimit 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": "…" }
  • warehouses comes only with include=stock, and price_types only with include=price_types.
  • price is in the variant's own currency, as typed in the product form. price_storefront is what the storefront shows, in the shop's main currency.
  • stock is 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 }
] }
  • price is in the variant's own currency, the one shown in the product form and returned as currency. For a variant priced in USD, send USD; the shop recalculates the storefront price at its rate.
  • Without price_type_id the row sets the base price, and optionally old_price. With price_type_id it sets the price of that price type; old_price is 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_GUARD unless 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 set force on every row by default.

Orders​

GET /orders (orders:read):

Parameter
updated_sinceISO-8601 or unix seconds; sorted by update time
created_sinceSame, but by creation time; use it for "new orders only"
statusComma list of status ids
paid1/true or 0/false
cursor, limitlimit 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 webhooks

updated_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.

A status change is not just a field

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;
    • carrier is 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 empty number clears 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
cursorThe next_cursor from your previous call (digits). Leave it empty the first time.
limit1..1000, default 500
entityComma list: order, product, price, stock
EntityScopeEvents
orderorders:readorder.created, order.status_changed, order.paid_changed, order.tracking_changed, order.cancelled, order.deleted
productcatalog:readproduct.saved (admin form), price.changed (mass price edit in the admin, or a price write through the API)
priceprices:readprice.updated, one row per variant written through this API
stockstock:readstock.updated, one row per variant written through this API
  • Save next_cursor only after you have processed the page, then ask again. An empty page returns the same cursor.
  • While has_more is true, 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_changed covers the admin, this API and online payment gateways. order.tracking_changed is written only by PATCH /orders/{id}/ttn of 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 /stock walk.

Webhooks​

Webhooks tell you about order changes as they happen. Manage them with a token that has webhooks:manage:

  • GET /webhooks lists this token's webhooks: {events, webhooks}, where events is 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 gives 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 }

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, paid and total only, with no buyer data. Fetch GET /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​

  1. At startup, read GET /warehouses and map your warehouses to the shop's warehouse_id.
  2. Build rows {sku | external_id | variant_id, warehouse_id, quantity} from your system's current balances, using PUT /stock with absolute values. Absolute values heal themselves: a missed run is fixed by the next one.
  3. Send up to 1000 rows per request, with an Idempotency-Key per chunk (for example stock-<run id>-<chunk no>).
  4. Log the rows with status: error:
    • AMBIGUOUS: switch that row to variant_id;
    • VARIANT_NOT_FOUND: the product is missing in the shop;
    • never "fix" either of them by writing to all matching variants.
  5. Stay under 60 writes per minute. On 429, sleep Retry-After seconds.

Push prices from a price list​

Same as stock, with PUT /prices:

  • run with dry_run first and look at the rows refused with PRICE_GUARD;
  • set force only for rows a person has checked;
  • send prices in the variant's currency (check currency in GET /prices).

Export new orders to accounting​

  • With webhooks: subscribe to order.created and order.status_changed. On each delivery, fetch GET /orders/{id} and upsert it by id.
  • Without webhooks, or as a safety net: every few minutes, call GET /changes?entity=order&cursor=<saved>, then GET /orders/{id} for each id, then save next_cursor.
  • For a first load or a full resync, use GET /orders?created_since=… and follow next_cursor. As a safety net, run GET /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:pii only if accounting really needs the buyer's contacts.

Send shipment status and tracking back​

  • PATCH /orders/{id}/ttn with the tracking number.
  • PATCH /orders/{id}/status with the shop's status_id. Read GET /order-statuses and agree the mapping with the shop owner.
  • Send an Idempotency-Key with 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​

SymptomCause
401 SIGNATURE_INVALID on every writeThe 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_EXPIREDThe server clock is off by more than 300 s; turn on NTP.
401 SIGNATURE_REPLAYEDA 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_ALLOWEDThe 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 itUpdate 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 /changesThe 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_runThe flag was sent as a string or number. Send true or false.
403 FORBIDDENThe token lacks the scope. Ask the shop owner to add it.
429 TOO_MANY_FAILURES20 bad tokens or signatures in 15 minutes from this IP. Fix the configuration, then wait for the block to end.
A row says AMBIGUOUSSeveral variants share the SKU. Use variant_id.
Stock written through the API changes back after a whileAnother 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 priceprice is in the variant's currency and price_storefront is in the main currency, at the shop's rate.