Skip to content

Cartenvo Integration Hub docs

Integration Hub sync API: developer guide

The Integration Hub sync API for nopCommerce: API keys and scopes, the changes feed, orders, shipments, bulk stock and prices, errors and limits.

On this page

The sync API is the handful of calls an ERP, warehouse system or automation tool needs to stay in sync with a nopCommerce store: what changed, orders, products, customers, bulk stock and prices, order status, shipments, ERP numbers, and webhook registration. It applies to release 2 (API version 1).

It works alongside the official nopCommerce Web API, not instead of it: there are no calls to create products, manage settings or run the storefront.

  • Base address: https://your-store.example.com/api/cartenvo/v1/
  • Full description: openapi.json, also served by every store at /api/cartenvo/v1/openapi.json with its own address filled in. Import it into Insomnia or an API client generator.
  • Postman: postman_collection.json has every call, with the key and the store's address as collection variables (the stock and price examples are dry runs).
  • Zapier, Make, n8n and Pipedream: the automation guides.
  • JSON in and out, in the webhooks' shapes: camelCase, UTC dates with a Z, amounts in the store's primary currency.

Keys

Make a key in admin under Configuration → Integration Hub → API keys. It's shown once; only a hash is kept.

HTTP
GET /api/cartenvo/v1/me HTTP/1.1
Authorization: Bearer cih_3Jk9…

X-Api-Key: cih_… works too. Keys in the query string aren't accepted (they end up in server logs).

SettingWhat it does
ScopesWhat the key may do (below). A call without its scope gets 403 forbidden.
StoreA key for one store sees only that store's orders; others answer 404. Products belong to every store.
Allowed IP addressesAddresses and CIDR ranges, one per line. Others get 403 ip_not_allowed. Behind a proxy or CDN, turn on nopCommerce's forwarded-headers setting so the caller's address is seen.
Calls a minuteDefault 120. Over it: 429 rate_limited with Retry-After. Counted on each web server.
Key onTurn off to stop a key at once.

Keys are only accepted over HTTPS (plain HTTP only from the same computer, for testing).

ScopeCalls
orders:readGET orders, GET orders/{id}, GET shipments/{id}
orders:writePOST orders/{id}/status, POST orders/{id}/shipments, PATCH shipments/{id}, POST orders/{id}/notes
catalog:readGET products, GET products/{id}
stock:writePOST stock
prices:writePOST prices
customers:readGET customers, GET customers/{id}
references:writePUT/DELETE {orders,products,customers}/{id}/external-references/{system}
changes:readGET changes
webhooks:manageGET webhooks/events, GET/POST webhooks/targets, DELETE webhooks/targets/{id}

Every write is recorded in nopCommerce's activity log (Customers → Activity log, type "Integration Hub API write") with the key's name. Writes run as nopCommerce's built-in background-task customer, so no guest customers are created by API calls.

Errors

Every error is RFC 9457 problem details, as application/problem+json:

JSON
{
  "type": "https://cartenvo.com/plugins/integration-hub/docs/api#errors",
  "title": "Not possible for this order now.",
  "status": 409,
  "detail": "An order with payment status Paid can't be marked as paid.",
  "code": "not_allowed",
  "errors": null
}

Check code in your code; title and detail are for people. errors lists field problems for invalid requests.

StatuscodeMeaning
400invalid_requestThe body or a parameter isn't valid. See errors.
400too_many_linesMore than 1,000 lines in a bulk call. Split it.
401unauthorizedNo key, or a key that doesn't exist or is off.
403forbiddenThe key lacks the scope.
403https_requiredThe call came over plain HTTP.
403ip_not_allowedThe caller's address isn't in the key's allowlist.
404not_foundNo such order, product, customer, shipment or webhook (or another store's).
409not_allowedValid, but the order's state doesn't allow it (already paid, can't be cancelled, nothing left to ship).
409idempotency_key_in_progressA request with the same Idempotency-Key is still running.
422not_allowedA shipment's quantities or warehouses don't fit.
422idempotency_key_reusedThe Idempotency-Key was used for a different request.
429rate_limitedToo many calls. Wait Retry-After seconds.
500server_errorSomething failed in the store. The details are in nopCommerce's log.

Idempotency

Send an Idempotency-Key header (any unique string up to 200 characters, such as a UUID) with every write. If the same request is sent again with the same key within 24 hours (a retry after a timeout), it isn't applied again: the first answer is returned, with Idempotent-Replayed: true. The same key with a different request is refused.

Shell
curl -X POST https://shop.example.com/api/cartenvo/v1/orders/42/shipments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a4e-3d2b-4c55-9a1d-2e3f4a5b6c7d" \
  -d '{"trackingNumber": "1Z999AA10123456784", "ship": true}'

Keeping in sync: the changes feed

GET changes answers "what changed since I last asked", so a sync doesn't have to list everything and compare.

HTTP
GET /api/cartenvo/v1/changes?since=c1.1830&types=order,stock HTTP/1.1
JSON
{
  "items": [
    { "type": "order", "id": 42, "deleted": false, "storeId": 1, "changedOnUtc": "2026-11-03T09:30:00.000Z" },
    { "type": "stock", "id": 17, "deleted": false, "storeId": 0, "changedOnUtc": "2026-11-03T09:30:02.120Z" }
  ],
  "cursor": "c1.1907",
  "hasMore": false
}
  1. First sync: call GET changes?since=latest and save its cursor. Then read everything once with the list calls (GET orders, GET products… paging with afterId).
  2. Every few minutes: call GET changes?since={saved cursor}. For each item, fetch the entity (GET orders/42) or treat it as deleted. Save the new cursor after you've handled the items. While hasMore is true, call again at once.
  3. If a run fails halfway, run again from the last saved cursor. Handling an item twice must be harmless.

How it behaves:

  • Changes are recorded only while an enabled key has changes:read, so a store that doesn't use the feed pays nothing. Changes made before that aren't in it: that's what the first full read is for.
  • Each order, product, stock level or customer appears once, at its latest change. The feed says what changed; fetch the entity for how it is now.
  • order covers the order, its statuses and its shipments. product covers product details, combinations and prices. stock is a stock change of the product or one of its combinations (the ID is the product's). customer covers registered customers' details, and deletions (deleted: true, nothing else).
  • Changes from the last 2 seconds wait for the next call, so a change saved a moment earlier can't be skipped.
  • Products changed by bulk SQL updates that skip nopCommerce's services raise no event. A daily task adds products whose updatedOnUtc moved without an event; it can't see changes that don't touch that date.
  • types filters the answer; keep a cursor per filter if you use different ones.

Orders

GET orders/{id} returns the order in the same shape as the webhooks' data: items with SKUs, addresses, totals, tax, payment and shipping, and externalReferences. GET orders lists orders oldest first, 50 per page (up to 100 with limit), filtered by status, paymentStatus, shippingStatus, createdSince, paidSince, or externalSystem and externalReference. Pass the answer's nextAfterId as afterId for the next page.

Status

HTTP
POST /api/cartenvo/v1/orders/42/status
{ "orderStatus": "Complete", "paymentStatus": "Paid" }
FieldValuesHow it's done
paymentStatusPaidnopCommerce's "mark as paid" (its emails and order.paid follow)
RefundedAn offline refund of the whole order
VoidedAn offline void
orderStatusPending, Processing, CompleteAs admin's "Change status"
CancellednopCommerce's cancel: stock goes back. notifyCustomer: true emails the customer

Payment is applied first, then the order status. A change the order's state doesn't allow answers 409. The shipping status follows the shipments. The answer is the order.

Shipments

HTTP
POST /api/cartenvo/v1/orders/42/shipments
{
  "items": [ { "sku": "TSHIRT-RED-L", "quantity": 2 }, { "orderItemId": 108, "quantity": 1, "warehouseId": 2 } ],
  "trackingNumber": "1Z999AA10123456784",
  "ship": true,
  "notifyCustomer": true
}
  • Leave items out to ship everything not yet in a shipment.
  • Items are found by orderItemId or by SKU (the product's, or the combination's that was bought).
  • warehouseId is needed only for products stocked in several warehouses.
  • ship: true marks it as shipped (or ready for pickup, for pickup orders); deliver: true also marks it as delivered. nopCommerce's emails go out unless notifyCustomer is false.
  • Quantities over what's left, or a missing warehouse, answer 422 with the item in errors. Nothing is saved.

PATCH shipments/{id} with trackingNumber, ship or deliver changes a shipment later.

Notes

POST orders/{id}/notes with {"note": "Invoice INV-2026-0042 sent", "displayToCustomer": true} adds a note. Shown to the customer, it's also emailed to them, as admin does.

External references

Save the order's (or product's, or customer's) number in your system, so both sides can find each other:

HTTP
PUT /api/cartenvo/v1/orders/42/external-references/odoo
{ "reference": "SO0042" }
  • The system name is lower case letters, digits, dots, dashes and underscores (odoo, sap-b1, netsuite).
  • References appear as externalReferences in API answers and in webhooks, so a receiver knows the order is already in the ERP.
  • Find by reference: GET orders?externalSystem=odoo&externalReference=SO0042 (also on products and customers).
  • DELETE …/external-references/odoo removes it.

Other plugins (such as the Cartenvo ERP connectors) use the same references through IExternalReferenceStore.

Catalog

GET products/{id} and GET products (filters sku, which also matches combination SKUs, updatedSince, includeDeleted, and the external reference) return products with their combinations, stock and prices.

Stock

HTTP
POST /api/cartenvo/v1/stock
{
  "reason": "Odoo sync",
  "lines": [
    { "sku": "TSHIRT-RED-L", "quantity": 25 },
    { "sku": "MUG-01", "adjustment": -3 },
    { "sku": "DESK-OAK", "warehouseId": 2, "quantity": 4 }
  ]
}

Up to 1,000 lines. Find each item by sku (a product's or a combination's), or by productId and optionally combinationId. Give quantity (the new stock) or adjustment (to add, or remove when negative).

Each line is applied on its own, as the product page in admin would: the stock history gets an entry with your reason, back-in-stock emails go out when a product comes back, and product.stock_changed webhooks follow.

JSON
{
  "dryRun": false, "updated": 2, "unchanged": 0, "failed": 1,
  "lines": [
    { "index": 0, "status": "updated", "sku": "TSHIRT-RED-L", "productId": 12, "combinationId": 40, "warehouseId": null, "oldQuantity": 20, "newQuantity": 25 },
    { "index": 1, "status": "updated", "sku": "MUG-01", "productId": 7, "combinationId": null, "warehouseId": null, "oldQuantity": 10, "newQuantity": 7 },
    { "index": 2, "status": "failed", "sku": "DESK-OAK", "productId": 9, "combinationId": null, "warehouseId": 2, "error": "warehouse_unknown", "message": "There's no warehouse 2." }
  ]
}
Line errorMeaning
not_foundNo product or combination has the SKU (deleted products aren't found).
ambiguousA product and another product's combination share the SKU: give productId or combinationId.
stock_not_trackedThe product doesn't track stock (or not per combination).
use_combination_skuThe product keeps stock per combination: use the combination's SKU.
warehouse_neededThe product is stocked in several warehouses: give warehouseId.
warehouse_unknownNo such warehouse, or the product isn't stocked per warehouse.
invalidThe line itself isn't valid (both quantity and adjustment, a negative price…).

Add ?dryRun=true (or "dryRun": true) to check everything and see what would change, without changing it.

Prices

POST prices takes the same kind of lines with price, oldPrice and productCost. Combinations have only a price (their overridden price). Each changed line reports changes, such as {"price": {"from": 24.5, "to": 22}}, and product.price_changed webhooks follow.

Customers

GET customers/{id} and GET customers?email= return registered customers with their roles and addresses. No passwords or internal fields are ever included.

Webhooks from outside

Tools like Zapier, Make and n8n register a webhook when a flow is turned on and remove it when it's turned off:

HTTP
POST /api/cartenvo/v1/webhooks/targets
{ "name": "n8n: new orders", "url": "https://n8n.example.com/webhook/abc", "events": ["order.placed"], "format": "full" }

The answer includes the signing secret, once. GET webhooks/targets lists the webhooks this key registered, DELETE webhooks/targets/{id} removes one, and GET webhooks/events lists the events. Webhooks made in admin aren't visible to the API, and deleting a key deletes its webhooks. The webhook rules apply: HTTPS only, the private-network block, signing and retries.

Limits

WhatLimit
Lines per bulk call1,000
Changes per page500 (default 100)
Items per list page100 (default 50)
Calls per key120 a minute by default, set per key
Idempotency keys kept24 hours
Order note4,000 characters
External reference200 characters; system name 40

Versions

The path carries the API version (v1). Within it, fields and calls are only added; anything that would break a client comes as v2, next to v1.