Ids, money, dates, envelopes, duplicates, pagination, idempotency, errors, rate limits, dry runs and historical imports.
Every Channel API endpoint follows the same rules. Learn them once here; the endpoint reference assumes them.
Ids
Every document has a Kentro id, a uuid. Three forms address a document wherever a path takes :id or a body takes an id:
| Form | Example | Notes |
|---|---|---|
| uuid | 6512aa00-bb11-cc22-dd33-ee4400000000 | What every response carries. |
| legacy 24-hex id | 6512aa00bb11cc22dd33ee44 | The objectId of the legacy Channel API; the same record. |
ext:<channelId>:<externalId> | ext:64f0aabb-ccdd-1122-3344-000100000000:shop-1001 | Your own id for the document inside one sales channel. |
externalId is the integration's own identifier for a document, unique within its sales channel. Send it on every create: it is what makes onDuplicate find the document again, and what ext: addressing resolves.
Money
Money travels as decimal strings in currency units with an explicit or implied currency: "unitPrice": "12.50" with "currency": "USD". Numbers are accepted on input and treated the same way. An amount with more decimals than its currency allows is 400 validation_failed with detail code precision. Responses always use strings. There are no cents anywhere on the wire.
Totals are what the channel charged: subtotal, shipping, salesTax, dutyTax, vat, discount, total, paid, refunded. The API checks that they add up; a mismatch is 422 totals_mismatch with the sent and computed amounts in details.
Dates
Timestamps are ISO-8601 in UTC (2026-11-08T14:03:00Z). Date-only fields (dueDate, shipNotBefore, purchaseOrderDate) accept YYYY-MM-DD, anchored at noon UTC so the calendar day survives every time zone. externalCreatedAt and externalUpdatedAt record when the document happened on the channel; createdAt and updatedAt are Kentro's.
Envelopes
| Request | Answer |
|---|---|
GET /v1/<resource>/:id | 200 { "data": { ... } } |
GET /v1/<resource> | 200 { "data": [ ... ], "nextCursor": "..." or null, "limit": 50 } |
POST, PATCH, DELETE, actions | 201 or 200 { "data": { ... }, "outcome": "...", "warnings"?: [ ... ] } |
Bulk import (POST /v1/orders/import, POST /v1/products/import) | 202 { "data": { "operationId": "..." } }, then GET /v1/operations/:id |
| Any failure | { "error": { "code", "message", "details"?, "requestId" } } |
outcome is one of created, updated, skipped, deleted, unchanged. unchanged means the write found nothing to change and wrote nothing, so it emitted no events either. warnings lists what the write accepted with a caveat (product_twins, unresolved_product, reservation_short, ...); each carries a code, a message and the ids involved.
Duplicates: onDuplicate
onDuplicateA POST that finds the document it describes (by id, by externalId in the channel, and for customers by email then phone) does what onDuplicate says:
onDuplicate | Answer when the document exists |
|---|---|
update (default) | 200 updated or 200 unchanged: the create body is applied as an update. |
skip | 200 skipped: the stored document is answered, nothing written. |
error | 409 duplicate with the matching field in details. |
So a create is an upsert by default. An integration that replays its full feed can POST every document every time.
Partial updates and version
versionPATCH /v1/<resource>/:id applies the fields sent and leaves the rest. Every document carries version, an opaque optimistic-concurrency token. Send it back as version in the PATCH body to refuse the update if the document changed in between (409 version_conflict); leave it out to update unconditionally.
Pagination
Lists are cursor based and never count totals. limit is 1 to 200 (default 50). The answer's nextCursor is the cursor of the next request; null means the last page. Order is newest first, stable across pages. Lists filter by channelId, externalId, status, createdSince, updatedSince and resource-specific fields; the reference lists each one.
updatedSince is the field for incremental sync: pass the latest updatedAt seen so far.
GET /v1/<resource>/:id expands every relation (include=items,customer on orders); a list expands none unless include asks for it.
Idempotency
Every POST, PUT, PATCH and DELETE takes an Idempotency-Key header: any string of 7 to 50 characters that identifies the request on your side (shop-1001-create, a webhook delivery id). The first request with a key is executed and its answer stored for 24 hours.
| Retry | Answer |
|---|---|
| Same key, same body | The stored answer, with Idempotent-Replayed: true. |
| Same key, different body | 422 idempotency_key_reused. |
| Same key while the first is still running | 409 in_progress with Retry-After. |
Keys are scoped per tenant and per endpoint. A request that failed is not stored: a retry with the same key runs again. The legacy query parameter ?idempotency= is accepted as an alias.
Errors
Every refusal past the gateway is JSON with one error object. details, when present, is a list of { path, code, message } naming the field.
{
"error": {
"code": "validation_failed",
"message": "Request validation failed",
"details": [
{ "path": "items[0].quantity", "code": "minimum", "message": "must be >= 1" }
],
"requestId": "2f408915-2608-4f0e-90a7-2a7abd34f487"
}
}
| Status | code | Meaning |
|---|---|---|
| 400 | validation_failed | A field is missing, malformed or unknown. Unknown fields are refused, never dropped. |
| 401 | unauthenticated | No usable identity reached the API. |
| 403 | forbidden | The key lacks the scope, or writes into a channel it is not bound to (not_bound). |
| 404 | not_found | No document at that id in this tenant. |
| 409 | duplicate | onDuplicate: error and the document exists. |
| 409 | state_conflict | The document's state forbids the action (cancel a shipped order, delete a product that holds stock). details[0].code names the case. |
| 409 | in_progress | The same Idempotency-Key is being processed. |
| 409 | version_conflict | The version sent is stale. |
| 422 | totals_mismatch | The totals do not add up to the lines and charges. |
| 422 | inactive_customer | The customer is inactive. |
| 422 | inventory_unavailable | The stock the action needs is not there. |
| 422 | unresolved_reference | A referenced document does not exist: an unknown SKU (not_found), a product group instead of a sellable variant (group_container), an unknown warehouse. |
| 422 | idempotency_key_reused | The key was used with a different body. |
| 429 | rate_limited | Over the tenant's rate limit; Retry-After says when to retry. |
| 503 | route_disabled | Kentro paused this endpoint; retry later. |
| 503 | dependency_unavailable | A dependency was briefly unavailable; retry with the same Idempotency-Key. |
| 500 | internal | Reported to Kentro with the requestId. |
requestId is also the X-Request-Id response header. Quote it when contacting support.
Rate limits
Limits are per tenant and per minute: 1,200 writes (POST, PUT, PATCH, DELETE) and 6,000 reads (GET) by default. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets). A refusal is 429 rate_limited with Retry-After. Higher limits are set per tenant by Kentro.
Dry runs and historical imports
Three optional request headers change how a write is applied. They are for integrations and bulk loads; a value outside the accepted set is 400 validation_failed.
| Header | Effect |
|---|---|
X-Dry-Run: true | The write runs in full and answers the status and body it would have produced, with X-Dry-Run: applied, then everything is rolled back. Nothing is stored, no event is emitted, no idempotency result is kept. |
X-Import-Mode: historical | The document is stored without raising the downstream effects a live document has: no confirmation email, packing slip, accounting export or EDI document. For loading a channel's history. Pair it with fulfillment.skip and per-line fulfilledQuantity on orders, and shippedAt on shipments. |
X-Customer-Match: externalId | Customers are matched by id and externalId only, never by email or phone, so two source records that share an email stay two customers. |
Asynchronous operations
POST /v1/orders/import and POST /v1/products/import accept up to a batch of documents and answer 202 with an operationId. GET /v1/operations/:id answers status (queued, running, succeeded, failed, canceled) and, once finished, the per-document results; GET /v1/operations/:id/input answers the batch as it was received.
