Skip to content

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.

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.

  • 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/inventory call here needs inventory:self. Reading orders in step 5 needs a sales scope such as sales:orders:inventory:self. See Scopes.
  • Send the token as the whole Authorization header, with no Bearer prefix. 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.

This is the discovery call. Run it at startup.

Terminal window
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" }
]
}
  • warehouseUniqueId is 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.
  • name is 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": []
}

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.

Terminal window
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.

Terminal window
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.

{
"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 sku and warehouseUniqueId together. 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 stale before failureReason. A stale discard reports success: false with failureReason left UPDATE_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 on errorMessage. 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.

Terminal window
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.

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_quantity and Zentail deducts what the seller’s PENDING_PAYMENT and PENDING orders have reserved. You never read orders. The quantity on POST /v2/inventory is 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 on POST /v2/inventory for this.
  • Reserve yourself. Poll orders, work out what is reserved, and send the already-reduced number as quantity on v1.

The rest of this step is the second option.

Terminal window
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"
  • status takes a bare comma-separated list. PENDING_PAYMENT and PENDING hold stock. CANCELLED tells you units you set aside will not ship, so you can release them.
  • lastUpdatedTs returns orders updated at or after that time, so each poll picks up only what changed.
  • warehouseId narrows 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.

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.

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].componentQuantity

assembledQuantity 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.

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.

API changelog · Built 0c509dd3