---
title: 'Integration API: connect accounting, ERP and warehouse systems'
sidebar_label: Integration API
description: 'Server-to-server REST API of the integration_api module: stock by warehouse, prices, orders, tracking numbers, changes feed and webhooks. Authentication, request signing, idempotency, limits and the full endpoint reference.'
---

# 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](./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.

:::tip[Handing this to an AI coding agent]
Give the agent this page, or these machine-readable copies:

- the article as plain Markdown: [`/integration-api/integration-api.md`](pathname:///integration-api/integration-api.md);
- the OpenAPI 3 contract: [`/integration-api/openapi.yaml`](pathname:///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`](pathname:///integration-api/client.php.txt).

The section [Brief for an AI coding agent](#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:

   ```bash
   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](#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 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:

```json
{ "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.

:::note[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.

| # | 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=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:

```json
{ "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

```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

```python
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+

```js
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)

```bash
BODY='{"hello":"world"}'
TS=$(date +%s)
P=/integration_api/v1/ping
SIG=$(printf 'POST\n%s\n\n%s\n%s' "$P" "$TS" "$(printf '%s' "$BODY" | sha256sum | cut -d' ' -f1)" \
      | openssl dgst -sha256 -hmac "$INTAPI_SECRET" | sed 's/^.* //')
curl -s "https://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:

```json
{
  "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:

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

`index` 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`. |

```json
{ "items": [ {
    "id": 512, "name": "…", "active": true, "archive": false, "url": "…",
    "category_id": 14, "category_ids": [14, 3], "brand_id": 7, "external_id": null,
    "created_at": "2025-04-01T06:12:00Z", "updated_at": "2026-09-20T15:03:11Z",
    "variants": [ {
      "id": 2211, "product_id": 512, "sku": "RC-1234", "barcode": "4820000000000", "external_id": null,
      "name": "2 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).

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

**`PUT /stock`** (`stock:write`, signed) sets absolute quantities:

```json
{ "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`.

```json
{ "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):

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

- `price` 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_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. |

:::info[`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](#changes-feed) and [webhooks](#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:

```json
{
  "id": 7170, "external_id": null,
  "status": { "id": 1, "name": "Нове" },
  "paid": false, "paid_at": null,
  "total": 1349, "origin_total": 1499, "discount": 150, "delivery_price": 0,
  "delivery_method": { "id": 3, "name": "Нова Пошта" },
  "payment_method":  { "id": 2, "name": "Оплата при отриманні" },
  "user_id": 0,
  "created_at": "2026-09-27T07:02:11Z", "updated_at": "2026-09-27T07:02:11Z",
  "items": [ { "product_id": 512, "variant_id": 2211, "sku": "RC-1234", "name": "…", "variant_name": "2 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):

```json
{ "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`.

:::warning[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:

```json
{ "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:

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

| 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_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`.

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

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

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:

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

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.

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$ok  = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
    && hash_equals(hash_hmac('sha256', $ts . '.' . $raw, getenv('WEBHOOK_SECRET')), $sig);
if (! $ok) { http_response_code(401); exit; }
http_response_code(204);   // 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](#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.

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

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

## 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](#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. |
