Inventory integration guide
This guide is for a system that holds physical stock — a 3PL, a warehouse management system, or a storefront acting as a warehouse. It keeps Zentail’s picture of that stock correct, so a seller’s quantities stay in step on every channel they sell on.
What you’ll build
Section titled “What you’ll build”An integration that finds the warehouses a seller bound to it, reports the on-hand count for each SKU in them, and watches Zentail’s diagnostics so a stalled sync shows up. If you do your own reservation maths, it also reads pending orders.
Steps 1 to 4 each lead with a /v2/inventory route. Only a v1 route exists for step 5,
reading orders.
Fulfillment is a separate contract. If you also ship orders on Zentail’s behalf, see the shipping integration profile.
Before you start
Section titled “Before you start”- Register your application. Fill out this form with a unique application name, a contact email, a callback URI for the authentication flow, and a square logo of at least 50 × 50 px. Zentail sends back a client ID and secret.
- Settle your scopes before you register. They are fixed at registration and cannot be
widened for a seller who has already installed you. Every
/v2/inventorycall here needsinventory:self. Reading orders in step 5 needs asalesscope such assales:orders:inventory:self. See Scopes. - Send the token as the whole
Authorizationheader, with noBearerprefix. See Zentail authentication flow.
Every call is served from https://api.zentail.com and scoped to your integration through
the token. No request carries a warehouse id or a company id. What you can read and write is
decided by which warehouses a seller has bound to your integration.
An error from the /v2/inventory routes is a google.rpc.Status object: a numeric gRPC code,
a message and a details array. You will see 7 (PERMISSION_DENIED, HTTP 403), 3
(INVALID_ARGUMENT, HTTP 400) and 9 (FAILED_PRECONDITION, also HTTP 400). A token without
inventory:self gets a 403:
{ "code": 7, "message": "missing required scope \"inventory:self\"", "details": []}A token that carries the scope but resolves to no integration gets the same 403 with a different message, because an integration is what every warehouse here is reached through:
{ "code": 7, "message": "this API token is not bound to an integration", "details": []}v1 errors have a different shape. See Errors.
1. List your warehouses
Section titled “1. List your warehouses”This is the discovery call. Run it at startup.
curl -X GET "https://api.zentail.com/v2/inventory/warehouses" \ -H "Authorization: <access token>" \ -H "Accept: application/json"{ "warehouses": [ { "warehouseUniqueId": "Warehouse1", "name": "Main Warehouse" }, { "warehouseUniqueId": "Warehouse2", "name": "Overflow" } ]}warehouseUniqueIdis the key. It is your own identifier for the warehouse, as the seller bound it, not Zentail’s internal id. Every later read and write is addressed by it, and it is never empty.nameis a label for your logs and for support conversations. It is not stable, so never match on it.
Call this rather than hard-coding identifiers. A seller can change the identifier they bound, and a hard-coded value then breaks silently.
A warehouse bound with no identifier is left out, because there is no value you could send to address it. If the list is empty, stop and get the seller’s binding fixed. Steps 2 and 3 both fail with a 400 in that state:
{ "code": 9, "message": "no warehouse is bound to integration 412 with an inventory identifier", "details": []}2. Read what Zentail holds
Section titled “2. Read what Zentail holds”This returns what Zentail believes is on hand for the SKUs in your warehouses. Use it to scope a sweep, and to send only the SKUs whose count has really changed.
curl -X GET "https://api.zentail.com/v2/inventory?updatedSince=2026-09-01T00:00:00Z&pageSize=200" \ -H "Authorization: <access token>" \ -H "Accept: application/json"{ "items": [ { "sku": "TESTSKU1", "warehouseUniqueId": "Warehouse1", "quantity": 13, "lastUpdatedTs": "2026-09-19T14:24:24Z" }, { "sku": "TESTSKU2", "warehouseUniqueId": "Warehouse2", "quantity": 0, "lastUpdatedTs": null } ], "nextCursor": "b3JkPTQxMg"}There is one item per SKU and warehouse, so a SKU stocked in two of your warehouses appears
twice. lastUpdatedTs is when the count was last changed by anyone, not only by you. It
is null when nothing has set it.
| Parameter | Type | Description |
|---|---|---|
updatedSince |
string ($date-time) |
Only SKUs whose Zentail-side quantity changed since this time. Leave it out for everything. |
cursor |
string |
The nextCursor from the previous page. Leave it out for the first page. |
pageSize |
integer |
Rows per page. Defaults to 200 and is capped at 1000. |
A pageSize above the cap is reduced to it rather than rejected. nextCursor is empty on
the last page, so loop until it is empty rather than counting rows.
3. Push on-hand quantities
Section titled “3. Push on-hand quantities”curl -X POST "https://api.zentail.com/v2/inventory" \ -H "Authorization: <access token>" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "observedTs": "2026-09-19T14:24:24Z", "updates": [ { "sku": "TESTSKU1", "warehouseUniqueId": "Warehouse1", "quantity": 13, "binLocation": "BIN-1" }, { "sku": "TESTSKU2", "warehouseUniqueId": "Warehouse2", "quantity": 6 } ] }'sku and warehouseUniqueId together name what you are setting. sku is matched exactly,
including case. warehouseUniqueId must be one that step 1 returned. binLocation is
optional free text for operator reference.
observedTs is required. It is when you observed the quantities, not when you sent them.
An update older than what Zentail already holds is discarded as stale, so a slow retry cannot
roll a count backwards. That guarantee only holds if you stamp the observation time. One
observedTs covers the whole request, so batch together only readings taken at the same
time. Leaving it out fails the call:
{ "code": 3, "message": "observed_ts is required", "details": [] }One request carries at most 50 updates. A larger one fails whole, with the limit in the message, rather than being truncated.
Read the response
Section titled “Read the response”{ "results": [ { "sku": "TESTSKU1", "warehouseUniqueId": "Warehouse1", "success": true, "errorMessage": "", "stale": false, "failureReason": "UPDATE_FAILURE_REASON_UNSPECIFIED" }, { "sku": "TESTSKU2", "warehouseUniqueId": "Warehouse2", "success": false, "errorMessage": "sku TESTSKU2 does not exist in Zentail", "stale": false, "failureReason": "UPDATE_FAILURE_REASON_UNKNOWN_SKU" } ]}- Match results on
skuandwarehouseUniqueIdtogether. There is one result per update, in no guaranteed order. - The call returns 200 even when items fail. A bad item fails on its own line and the rest still land.
- Read
stalebeforefailureReason. A stale discard reportssuccess: falsewithfailureReasonleftUPDATE_FAILURE_REASON_UNSPECIFIED. Treat it as success: the newer reading stands. If it happens constantly, look for clock skew or a second writer. - Branch on
failureReason, never onerrorMessage. The message is for logs, and its wording can change.
failureReason |
What happened | What to do |
|---|---|---|
UPDATE_FAILURE_REASON_UNKNOWN_SKU |
The SKU is not in Zentail’s catalog for this seller | Expected in bulk. Do not alert on it |
UPDATE_FAILURE_REASON_WAREHOUSE_NOT_YOURS |
warehouseUniqueId names no warehouse your integration owns |
Re-run step 1; you are sending a stale id |
UPDATE_FAILURE_REASON_MISSING_SKU |
The update carried no SKU | Fix the caller |
UPDATE_FAILURE_REASON_INTERNAL |
Zentail failed to apply the update | Retry |
UPDATE_FAILURE_REASON_INVALID |
Permanently unacceptable, such as a negative quantity | Do not retry unchanged |
UNKNOWN_SKU is the one to design around. Your catalog is not the seller’s, so a sweep over
your own stock will name SKUs they have never listed.
4. Watch the diagnostics
Section titled “4. Watch the diagnostics”curl -X GET "https://api.zentail.com/v2/inventory/status" \ -H "Authorization: <access token>" \ -H "Accept: application/json"{ "checks": [ { "name": "warehouses_bound", "state": "CHECK_STATE_PASS", "message": "2 warehouse(s) bound to this integration", "source": "CHECK_SOURCE_ZENTAIL", "warehouseUniqueId": "" }, { "name": "inventory_freshness", "state": "CHECK_STATE_WARN", "message": "inventory last reported at 2026-09-19T14:24:24Z", "source": "CHECK_SOURCE_ZENTAIL", "warehouseUniqueId": "" } ]}Build this into your monitoring, not only your test plan. An inventory integration fails quietly: when a sync loop stops, nothing errors and the numbers simply age. This call is where that shows up.
Match on name, which is stable. message is prose for a human and can change.
name |
CHECK_STATE_PASS |
CHECK_STATE_WARN |
CHECK_STATE_FAIL |
|---|---|---|---|
warehouses_bound |
at least one warehouse bound with an identifier | — | none bound, so every update you send is rejected |
inventory_freshness |
reported within the last 24 hours | last reported 24 or more hours ago | last reported 72 or more hours ago, or this integration has never reported |
A failing warehouses_bound is the empty list from step 1, seen from the other side. The
seller fixes it in Zentail; your code cannot.
A third check, integration_service, appears only when Zentail cannot reach an
integration that publishes its own health — see below.
source says who observed a check. CHECK_SOURCE_ZENTAIL is Zentail looking from the
outside. CHECK_SOURCE_INTEGRATION is an integration reporting on itself, with
warehouseUniqueId naming the warehouse when the check is about one. Zentail only folds
those in from an integration that implements WarehouseService, which is not published yet.
There is nothing for you to implement today, and nothing is missing without it.
This step has no v1 counterpart.
5. Reserve stock for pending orders
Section titled “5. Reserve stock for pending orders”A seller’s pending orders have claimed stock that is no longer sellable. If Zentail does not account for it, it keeps offering the same units on every channel.
Only a v1 route exists for orders. GET /v1/salesOrder is the route for this step
whichever inventory routes you use.
You have two ways to handle reservations. Pick one per SKU and stick to it.
- Let Zentail reserve. On v1, send the physical count as
onhand_quantityand Zentail deducts what the seller’sPENDING_PAYMENTandPENDINGorders have reserved. You never read orders. ThequantityonPOST /v2/inventoryis also an on-hand count, but this guide does not promise what Zentail deducts from it downstream. If your integration depends on that, confirm it before relying onPOST /v2/inventoryfor this. - Reserve yourself. Poll orders, work out what is reserved, and send the already-reduced
number as
quantityon v1.
The rest of this step is the second option.
Poll for the orders
Section titled “Poll for the orders”curl -X GET "https://api.zentail.com/v1/salesOrder?status=PENDING_PAYMENT,PENDING,CANCELLED&lastUpdatedTs=2021-01-22T16:15:46.740Z" \ -H "Authorization: <access token>" \ -H "Accept: application/json"statustakes a bare comma-separated list.PENDING_PAYMENTandPENDINGhold stock.CANCELLEDtells you units you set aside will not ship, so you can release them.lastUpdatedTsreturns orders updated at or after that time, so each poll picks up only what changed.warehouseIdnarrows the response to line items routed to one Zentail warehouse.
The other statuses — PARTIALLY_SHIPPED, SHIPPED, RETURNED, REFUNDED and
RETURN_REQUESTED — hold no reservation, so this loop does not need them. A return that puts
units back on the shelf is a physical change: report the new count as in step 3.
A trimmed response, with most order fields left out:
{ "results": [ { "orderNumber": "1000004", "status": "PENDING_PAYMENT", "lastUpdatedTs": "2021-01-22T09:01:48-05:00", "products": [ { "lineItemId": "1", "status": "PENDING_PAYMENT", "requestedSku": "TESTSKU1", "sku": "TESTSKU1", "quantity": 2, "cancelQuantity": null, "shippedQuantity": 0, "routing_info": [ { "warehouseId": 3, "warehouseUniqueId": "Warehouse1", "quantity": 2, "assembledQuantity": null, "kitComponents": null } ] } ] } ], "pagination": { "nextToken": null, "hasNext": false }}Every field is described on
the sales order operation page.
The response carries no customer address. That needs sales:orders:fulfillment:self, which
an inventory integration is not registered for.
Read the line items
Section titled “Read the line items”Reservations are tracked per line, so read each line’s status rather than the order’s. A
partially shipped order carries both kinds of line at once.
| Field | Use |
|---|---|
lineItemId |
Identifies the line within the order |
status |
The line’s own status, from the same list as the order’s |
sku |
The SKU Zentail chose to fulfill with. It is null when the line matches no product, so fall back to requestedSku |
requestedSku |
The SKU the sales channel asked for, which may not exist in Zentail |
quantity |
The quantity ordered. With the warehouseId filter, only the quantity routed to that warehouse |
cancelQuantity |
The quantity cancelled |
shippedQuantity |
The quantity already shipped |
routing_info |
Which warehouse each unit was routed to, and what a kit breaks down into |
What a line still holds is quantity minus cancelQuantity minus shippedQuantity.
Build against sku. The line also carries a deprecated SKU that duplicates it. The SKU
fields on the inventory routes and inside kitComponents are different fields and are not
deprecated.
routing_info[].warehouseUniqueId is returned only to an application registered as an
inventory, shipping or 3PL integration. It is your join back to your own warehouse.
Kits and multi-packs
Section titled “Kits and multi-packs”The SKU that sold may not be the SKU on the shelf. A kit SKU can draw its stock from one or more component SKUs.
Say Warehouse1 stocks TESTSKU1, and the seller also sells it as a two-pack,
TESTSKU1-2PK. An order for two of the two-pack arrives:
{ "orderNumber": "1000005", "status": "PENDING_PAYMENT", "products": [ { "lineItemId": "0", "status": "PENDING_PAYMENT", "requestedSku": "TESTSKU1-2PK", "sku": "TESTSKU1-2PK", "quantity": 2, "routing_info": [ { "warehouseId": 3, "warehouseUniqueId": "Warehouse1", "quantity": 2, "assembledQuantity": 0, "kitComponents": [ { "SKU": "TESTSKU1", "componentQuantity": 2 } ] } ] } ]}routing_info gives the real quantity to reserve. Both two-packs were routed to
Warehouse1, and each is built from 2 of TESTSKU1, so 4 units of TESTSKU1 are reserved:
routing_info[].quantity * routing_info[].kitComponents[0].componentQuantityassembledQuantity changes that sum when your warehouse builds kits ahead and reports the
built count to Zentail. It is how many of the ordered units come from kits already built. Had
it been 1 above, one unit would be reserved against the built TESTSKU1-2PK, and the other
as 2 of TESTSKU1.
Other order routes
Section titled “Other order routes”This profile’s reference also lists three more order routes. Only a v1 route exists for each
of them:
GET /v1/salesOrder/{orderNumber},
POST /v1/salesOrder/{orderNumber}/alert
and
POST /v1/salesOrder/{orderNumber}/confirmStatus.
The reservation loop above needs none of them.
See also
Section titled “See also”API changelog · Built 0c509dd3