Integration API
The Integration API module lets a warehouse program, 1C, a CRM or your own script work with the shop over HTTPS: read the catalog, set stock by warehouse, change prices, read orders, change their status and payment, set a waybill number (ТТН), and get a webhook when an order changes.
It is a closed, server-to-server API. Every call needs a token with its own rights, every write is signed and comes from an allowed IP address, and every change is recorded in the audit log.
The module is installed from Modules → Module store. It needs eCMS9 core build 526 or newer (build 600 for the product and article import).
Headless API serves a storefront: catalog, cart, checkout and customer login. It may be public, and it never changes prices or stock. Integration API is the opposite: nothing is public, and its job is to write back-office data. If you can read the catalog but cannot change a price or a quantity, you are most likely talking to Headless API, not to this module.
Where it starts
The base address is https://your-shop/integration_api/v1.
GET /ping— checks a token and returns its name, rights and the server time. Without a valid token every address answers401 UNAUTHORIZED; that is the normal state of a shop with no token issued.GET /openapi.yaml— the full contract as an OpenAPI file, ready for Postman, Insomnia or a code generator. The same link is the OpenAPI button on the module screen.
Get a token
Open Settings → API інтеграції (the module screen is called "Integration API"). In the Tokens block:
- Give the token a name, for example "Accounting — stock sync". One token per integration: you can then see its requests, switch it off, or replace it without touching the others.
- Tick the rights it needs. Give the minimum.
- For any right that writes, list the allowed IP addresses: a single address
or a network (not wider than
/16; for IPv6 not wider than/32). A write token cannot be created without this list. - Optionally set an expiry date.
After you press Create token, the screen shows two values once:
- the token
ik_…, sent in every request; - the signing secret, used to sign writes.
The shop stores only the token's fingerprint and does not store the secret at all, so copy both at once and hand them over by a protected channel. If a value is lost or leaked, use Replace token on it: a new pair is issued and the old one stops working immediately, with the name, rights, IP list and request history kept.
| Right | Allows |
|---|---|
catalog:read | products, variants, categories, brands |
stock:read / stock:write | read / change stock by warehouse |
prices:read / prices:write | read / change prices |
orders:read | read orders, without buyers' personal data |
orders:write | change the status, the payment mark and the waybill number |
customers:pii | name, phone and address of the buyer inside orders |
webhooks:manage | subscribe to order events |
products:write | import a product or a blog article from a sister shop |
Rights that write (stock:write, prices:write, orders:write,
webhooks:manage, products:write) need an IP list. Personal data is a
separate right on purpose: without it an order has no name, phone or address.
Make a request
Reads need only the token:
curl -sS https://your-shop/integration_api/v1/stock?sku=RC-1234 \
-H "Authorization: Bearer ik_xxxxxxxx"
The token is accepted only in the Authorization: Bearer header, never in the
address. Plain HTTP is refused.
Sign every write
A write also carries two headers:
X-Timestamp: <unix seconds>
X-Signature: hex(HMAC-SHA256(secret, METHOD + "\n" + PATH + "\n" + QUERY + "\n" + X-Timestamp + "\n" + hex(SHA256(body))))
PATHstarts with/, for example/integration_api/v1/stock.QUERYis the query string exactly as it goes on the wire, without?; empty if there is none.- The body is hashed byte for byte as sent; an empty body is the SHA-256 of an empty string.
- The secret is used as it is shown (the hex text, not decoded).
- The timestamp must be within ±300 seconds of the server time, and a signature
is accepted once. Two identical requests in the same second get
SIGNATURE_REPLAYED.
A complete example (a dry run of a stock change):
BASE=https://your-shop/integration_api/v1
TOKEN=ik_xxxxxxxx
SECRET=your-signing-secret
BODY='{"dry_run":true,"items":[{"sku":"RC-1234","warehouse_id":1,"quantity":12}]}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIG=$(printf 'PUT\n/integration_api/v1/stock\n\n%s\n%s' "$TS" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS -X PUT "$BASE/stock" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
-H "Idempotency-Key: stock-$TS" \
-H "Content-Type: application/json" \
--data-binary "$BODY"
Check your signing first with POST /ping: it must be signed, and its failures do
not count toward the IP block. The module also ships a small PHP client with the
same signing, Resources/examples/client.php.
What the API can do
| Area | Endpoints |
|---|---|
| Reference data | /warehouses, /currencies, /price-types, /order-statuses, /payment-methods, /delivery-methods |
| Catalog | /products (with variants, paged), /products/{id}, /variants (find by id, SKU, barcode or external id), /categories, /brands |
| Stock | GET /stock; PUT /stock sets absolute quantities per warehouse; POST /stock/adjust changes them by a delta |
| Prices | GET /prices; PUT /prices sets the base price, the old price or the price of a price type |
| Orders | GET /orders, GET /orders/{id}; PATCH /orders/{id}/status, /paid, /ttn |
| Changes | GET /changes — what changed after your cursor |
| Webhooks | GET, POST /webhooks, DELETE /webhooks/{id} |
Changing an order status does what the admin status change does, including the
side effects configured in your shop: CRM sync, a fiscal receipt, SMS or
Telegram notices, stock actions of the status. Try it with dry_run first.
Stock and prices
- A variant is named by exactly one identifier:
variant_id,sku,barcodeorexternal_id. A value that matches several variants is refused withAMBIGUOUS; usevariant_idfor those. - A batch holds up to 1000 rows. Valid rows are written in one transaction.
With
all_or_nothing: trueone bad row rejects the whole batch. priceis in the variant's own currency, as typed in the product form. The price in the main currency is recalculated from it.- A stock or price write goes through the same path as the admin form: price feeds, search and the page cache see it.
- A shop that does not use the Warehouses module has a single warehouse, id 1
(
GET /warehousesshows it). Send"warehouse_id": 1; the quantity is written straight to the variant's stock. Available from module version 1.3.1.
Orders and waybill numbers
GET /orders is paged by cursor; use updated_since for incremental sync.
PATCH /orders/{id}/ttn writes the waybill number. For Nova Poshta the number
goes into the Nova Poshta module (14 digits, and its status sync then tracks
it); for other deliveries it goes into the core's manual tracking field. The
current value is returned as tracking in GET /orders/{id}.
Setting paid runs everything a payment mark runs in the shop, including a
fiscal receipt when the fiscal module is on. Test it with dry_run first.
Keep in sync with the changes feed
GET /changes?cursor=… returns what changed since the cursor, as ids and event
names (order.status_changed, stock.updated, price.changed, …). Keep
next_cursor and ask again. Rows are kept for 30 days and filtered by the rights
of your token. Narrow it with entity=order,stock.
The feed sees changes that go through the shop's events. Stock that something
else moves (an hourly file import, a direct database sync) does not appear in
it. Walk GET /stock in full from time to time as a safety net.
Webhooks
Subscribe with POST /webhooks to order.created, order.status_changed,
order.paid_changed or order.tracking_changed. The address must be HTTPS on
port 443 or 8443 and resolve to a public host; up to 5 active per token. The
secret is returned once.
A delivery is a POST with X-Webhook-Event, X-Webhook-Id,
X-Webhook-Timestamp and
X-Webhook-Signature = hex(HMAC-SHA256(webhook_secret, timestamp + "." + body)).
Answer with 2xx within 10 seconds; otherwise it is retried after 1 min, 5 min,
30 min, 2 h, 6 h, 12 h and 24 h. The payload carries ids and states only, so
fetch the order for the details, and deduplicate by X-Webhook-Id.
Safety built in
- dry_run —
"dry_run": truein the body shows what would change and writes nothing. A flag that is not a real boolean is refused, never read as a live write. - Idempotency-Key — an 8–64 character key on a write: a retry returns the stored answer instead of applying twice (kept 24 hours).
- Price guard — a change of more than 50 % and a zero price are rejected with
PRICE_GUARDunless the row carries"force": true. - Rate limits — 300 reads and 60 writes per minute per token; over the limit
the answer is
429withRetry-After. - IP block — repeated failed authentication from one address blocks it for 15 minutes.
- Audit — every applied write is recorded in the admin audit log with the author "API: token name", the IP address and the old and new values.
- Request log — every request is listed in the Request log block of the module screen and kept for 90 days by default.
All of this is set in the Security and limits block of the module screen:
the Read only switch (every write answers 503, reads keep working), the
HTTPS requirement, the limits, the batch size, the price guard percent, the
signature window, and Telegram notices about a new token and an IP block.
Responses and errors
Every answer has the same envelope:
{"success": true, "data": {}, "error": null}
{"success": false, "data": null, "error": {"code": "UNAUTHORIZED", "message": "…"}}
The common codes: UNAUTHORIZED, IP_NOT_ALLOWED, FORBIDDEN (the token lacks
a right), SIGNATURE_MISSING, SIGNATURE_INVALID, SIGNATURE_EXPIRED,
SIGNATURE_REPLAYED, RATE_LIMITED, TOO_MANY_FAILURES, READ_ONLY,
VALIDATION_FAILED, IDEMPOTENCY_CONFLICT, INVALID_FIELD, AMBIGUOUS,
PRICE_GUARD. The full list is in openapi.yaml. Times in responses are UTC
ISO-8601 with Z; send Z or an offset in updated_since.
FAQ
Every call answers 401. There is no token yet, or it is in the wrong place.
Send it as Authorization: Bearer ik_…; it is not read from the address.
A write answers 401 SIGNATURE_INVALID. Compare what you sign with the
rules above: the path must start with /, the query is signed as sent, the body
is hashed as sent, the secret is the hex text. Check it with POST /ping.
A write answers 403 IP_NOT_ALLOWED. The caller's address is not in the
token's list. Behind a CDN or a proxy the shop sees the proxy's address unless it
is configured to trust it.
Several products share a SKU. Those rows answer AMBIGUOUS. Look the
variants up with GET /variants?sku=… and write by variant_id.
Stock is overwritten by another job. If an hourly import also writes the same warehouse, the two will fight. For each warehouse, choose one source.
Can the token be read-only? Yes. Tick only the …:read rights; such a token
needs no IP list and no signature.