Skip to content

3PL integration guide

This is the ordered path for a third-party logistics provider (3PL): a business that stores a Zentail seller’s stock in its own warehouses and ships that seller’s orders. A 3PL integration has two jobs. It ships the orders Zentail routes to it, and it tells Zentail how much stock it holds.

Shipping goes through the Shipping profile’s routes. Every shipping step below uses FulfillmentIntegrationService, the same /v2/shipping routes the shipping integration guide documents in full. This page gives the 3PL path through them and links that guide for the detail, rather than repeating it.

Stock goes through the Inventory profile’s routes. The warehouse lookup in step 1 and the stock updates in step 6 use InventoryIntegrationService, the same /v2/inventory routes the inventory integration guide documents in full.

The same token covers both jobs.

A poller that, every few minutes:

  1. reads the work Zentail wants shipped from your warehouses,
  2. records your own id for each piece of work,
  3. reports each package as it ships, or declines what you cannot ship,
  4. pulls back anything Zentail cancels,
  5. raises an alert when something holds a shipment up,
  6. and sends your current stock levels back to Zentail.
  • Register your application. Fill out the registration form with a unique application name, a contact email, a callback URI for the authentication flow, and a square logo at least 50px by 50px. Zentail sends back the client id and client secret that the authentication flow needs.
  • Ask for two scopes. sales:orders:fulfillment:self admits the shipping routes and their v1 counterparts. inventory:self admits the inventory and warehouse routes, and limits them to your own warehouses. Registration fixes your scopes, and they cannot be widened later for a seller who has already installed you. See Scopes.
  • The seller maps their warehouses to you. When a seller installs your application, they pick your integration as the warehouse’s fulfiller and its inventory source, and enter the warehouse identifier your own system uses. Until a warehouse is mapped, nothing is routed to you.

Every call goes to https://api.zentail.com. Send the access token as the whole Authorization header value, with no Bearer prefix. Each token belongs to one integration, which is one install of your application. A seller can install you more than once, so hold one token per integration and run the loop once per token.

No /v2/shipping request carries a warehouse id or a company id. The token resolves to your integration, and through it to the warehouses mapped to it.

The /v2/shipping routes send every 64-bit integer as a JSON string, so fulfillmentOrderId and orderNumber arrive as "10012345". They also page and report errors differently from the v1 routes. See Pagination and Errors.

Request GET /v2/shipping/status. It returns a list of diagnostic checks, and it answers before any work exists, so it is the first call to make.

{
"checks": [
{
"name": "integration_active",
"state": "CHECK_STATE_PASS",
"message": "this integration is active in Zentail",
"source": "CHECK_SOURCE_ZENTAIL"
},
{
"name": "warehouses_bound",
"state": "CHECK_STATE_PASS",
"message": "1 warehouse(s) bound to this integration",
"source": "CHECK_SOURCE_ZENTAIL"
}
]
}

Look for integration_active first. It fails when the integration has been switched off in Zentail. Every acknowledge call is then refused, and so are GET /v2/inventory, POST /v2/inventory and GET /v2/inventory/warehouses. You can still ship orders, reject them, confirm the cancellations Zentail asks for, and raise alerts.

Because you can’t acknowledge, a cancellation of an order you haven’t acknowledged never reaches you. Before you ship such an order, check that it is still in need_to_ship.

To get the integration switched back on, ask the seller to contact Zentail support at [email protected]. See Errors.

Then look for warehouses_bound. It fails until a warehouse names your integration as its fulfiller. Match on a check’s name, never on its message. The shipping guide lists every field a check carries.

Then list the warehouses your integration can stock with GET /v2/inventory/warehouses. Stock updates in step 6 key on the warehouseUniqueId it returns.

Terminal window
curl -X GET "https://api.zentail.com/v2/inventory/warehouses" \
-H "Authorization: <access token>" \
-H "Accept: application/json"
{
"warehouses": [
{ "warehouseUniqueId": "Warehouse 0001", "name": "Main Warehouse" }
]
}

warehouseUniqueId is your own identifier for the warehouse, as the seller mapped it. Store it against your own warehouse record, and re-read the list at startup rather than hard-coding it. name is a label for logs, so never match on it.

Request GET /v2/shipping/need_to_ship. It returns the fulfillment orders you owe a shipment on.

A fulfillment order is the part of one customer order routed to one of your warehouses. One order split across two of your warehouses gives you two fulfillment orders. Every write below names its work by fulfillmentOrderId. orderNumber is carried for display only, and no write accepts it.

Pass cursor and pageSize, and keep requesting until nextCursor comes back empty. There is no time filter, on purpose: the queue is drained by confirming work, never by moving a clock forward, so you cannot skip past work you failed to finish.

Three rules keep a poller correct:

  • Read lines fresh on every poll, and never cache it. A partial re-route lowers a line’s quantity rather than removing the fulfillment order.
  • A line’s quantity is what is routed to your warehouse and still owed, not what the buyer ordered.
  • Do not drive behaviour from status. The queues already say what Zentail wants done.

The shipping guide’s step 2 shows a full fulfillment order and explains each field, including the money fields and the ones that are always empty today.

When you create the work in your own system, record your id for it with POST /v2/shipping/fulfillment_orders/acknowledge:

{
"acknowledgements": [
{ "fulfillmentOrderId": "88120045", "externalOrderId": "WMS-ORD-5531" }
]
}

Acknowledging does not remove work from need_to_ship, and it is not required before shipping. Do it anyway. It is what puts the work into the cancel queue in step 5, and it lets you tell, after a crash, which work you had already created.

Send at most 50 per call. Read each entry in results rather than the HTTP status, because one entry failing does not fail the rest. alreadyAcknowledged: true means a retry, and counts as success.

Report each package as it ships with POST /v2/shipping/shipments. It removes the shipped quantities from need_to_ship.

{
"shipments": [
{
"fulfillmentOrderId": "88120045",
"externalShipmentId": "PKG-000918",
"carrier": "UPS",
"trackingNumber": "1Z999AA10123456784",
"serviceLevel": "Ground",
"shippingCost": { "amount": "8.41", "currencyCode": "USD" }
}
]
}

fulfillmentOrderId, externalShipmentId, trackingNumber and serviceLevel are refused when empty, so send all four. externalShipmentId is your package id and the idempotency key: replaying a shipment changes nothing, and alreadyRecorded: true says so. Leave out lines to ship everything the fulfillment order still owes.

Send tracking once, and get it right. Once Zentail passes it to the sales channel, most channels do not accept a correction.

If you cannot ship the work at all, decline it with POST /v2/shipping/fulfillment_orders/reject and a reason, such as REJECTION_REASON_OUT_OF_STOCK. Zentail then reroutes it or shows it to the seller. Reject the whole fulfillment order: naming lines fails the entry.

The shipping guide’s step 4 covers the optional fields, the cost fields and every rejection reason.

Request GET /v2/shipping/need_to_cancel on the same loop. It returns acknowledged fulfillment orders that Zentail now wants pulled back, and it pages the same way as need_to_ship. Once you have stopped the work, confirm it with POST /v2/shipping/fulfillment_orders/confirm_cancellations:

{ "confirmations": [{ "fulfillmentOrderId": "88120045" }] }

If the package has already gone, still confirm, with alreadyShipped: true, and report the package through step 4. Zentail needs both to keep the customer order truthful.

When something is holding a shipment up and you still mean to ship it, raise an alert with POST /v2/shipping/fulfillment_orders/alerts. The seller sees it on the order. Raising is idempotent, so re-raise a condition on every poll without keeping your own record. Clear it with POST /v2/shipping/fulfillment_orders/alerts/resolve. An alert is not a substitute for rejecting: reject work you will not ship.

The shipping guide’s step 5 and step 6 cover cancellation reasons, partial cancellations and the alert types.

Tell Zentail how much of each SKU you hold with POST /v2/inventory. Name each warehouse by the warehouseUniqueId from step 1.

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": "SKU_00000", "warehouseUniqueId": "Warehouse 0001", "quantity": 1389 }
]
}'
  • observedTs is required. It is when you counted, not when you sent. Zentail discards an update older than what it already holds, so a slow retry can’t roll a count backwards.
  • One request carries at most 50 updates. A larger one fails whole.
  • A 200 does not mean every update landed. Each update gets its own result. Match results on sku and warehouseUniqueId, and branch on failureReason. The inventory guide explains each reason, and which ones to retry.

If anything here is unclear, email [email protected].

API changelog · Built f1d143e6