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.jsonwith its own address filled in. Import it into Insomnia or an API client generator. - Postman:
postman_collection.jsonhas 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.
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).
| Setting | What it does |
|---|---|
| Scopes | What the key may do (below). A call without its scope gets 403 forbidden. |
| Store | A key for one store sees only that store's orders; others answer 404. Products belong to every store. |
| Allowed IP addresses | Addresses 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 minute | Default 120. Over it: 429 rate_limited with Retry-After. Counted on each web server. |
| Key on | Turn off to stop a key at once. |
Keys are only accepted over HTTPS (plain HTTP only from the same computer, for testing).
| Scope | Calls |
|---|---|
orders:read | GET orders, GET orders/{id}, GET shipments/{id} |
orders:write | POST orders/{id}/status, POST orders/{id}/shipments, PATCH shipments/{id}, POST orders/{id}/notes |
catalog:read | GET products, GET products/{id} |
stock:write | POST stock |
prices:write | POST prices |
customers:read | GET customers, GET customers/{id} |
references:write | PUT/DELETE {orders,products,customers}/{id}/external-references/{system} |
changes:read | GET changes |
webhooks:manage | GET 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:
{
"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.
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | The body or a parameter isn't valid. See errors. |
| 400 | too_many_lines | More than 1,000 lines in a bulk call. Split it. |
| 401 | unauthorized | No key, or a key that doesn't exist or is off. |
| 403 | forbidden | The key lacks the scope. |
| 403 | https_required | The call came over plain HTTP. |
| 403 | ip_not_allowed | The caller's address isn't in the key's allowlist. |
| 404 | not_found | No such order, product, customer, shipment or webhook (or another store's). |
| 409 | not_allowed | Valid, but the order's state doesn't allow it (already paid, can't be cancelled, nothing left to ship). |
| 409 | idempotency_key_in_progress | A request with the same Idempotency-Key is still running. |
| 422 | not_allowed | A shipment's quantities or warehouses don't fit. |
| 422 | idempotency_key_reused | The Idempotency-Key was used for a different request. |
| 429 | rate_limited | Too many calls. Wait Retry-After seconds. |
| 500 | server_error | Something 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.
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.
GET /api/cartenvo/v1/changes?since=c1.1830&types=order,stock HTTP/1.1{
"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
}- First sync: call
GET changes?since=latestand save itscursor. Then read everything once with the list calls (GET orders,GET products… paging withafterId). - 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 newcursorafter you've handled the items. WhilehasMoreis true, call again at once. - 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.
ordercovers the order, its statuses and its shipments.productcovers product details, combinations and prices.stockis a stock change of the product or one of its combinations (the ID is the product's).customercovers 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
updatedOnUtcmoved without an event; it can't see changes that don't touch that date. typesfilters 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
POST /api/cartenvo/v1/orders/42/status
{ "orderStatus": "Complete", "paymentStatus": "Paid" }| Field | Values | How it's done |
|---|---|---|
paymentStatus | Paid | nopCommerce's "mark as paid" (its emails and order.paid follow) |
Refunded | An offline refund of the whole order | |
Voided | An offline void | |
orderStatus | Pending, Processing, Complete | As admin's "Change status" |
Cancelled | nopCommerce'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
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
itemsout to ship everything not yet in a shipment. - Items are found by
orderItemIdor by SKU (the product's, or the combination's that was bought). warehouseIdis needed only for products stocked in several warehouses.ship: truemarks it as shipped (or ready for pickup, for pickup orders);deliver: truealso marks it as delivered. nopCommerce's emails go out unlessnotifyCustomeris false.- Quantities over what's left, or a missing warehouse, answer
422with the item inerrors. 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:
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
externalReferencesin 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/odooremoves 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
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.
{
"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 error | Meaning |
|---|---|
not_found | No product or combination has the SKU (deleted products aren't found). |
ambiguous | A product and another product's combination share the SKU: give productId or combinationId. |
stock_not_tracked | The product doesn't track stock (or not per combination). |
use_combination_sku | The product keeps stock per combination: use the combination's SKU. |
warehouse_needed | The product is stocked in several warehouses: give warehouseId. |
warehouse_unknown | No such warehouse, or the product isn't stocked per warehouse. |
invalid | The 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:
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
| What | Limit |
|---|---|
| Lines per bulk call | 1,000 |
| Changes per page | 500 (default 100) |
| Items per list page | 100 (default 50) |
| Calls per key | 120 a minute by default, set per key |
| Idempotency keys kept | 24 hours |
| Order note | 4,000 characters |
| External reference | 200 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.