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 is on v1 for now. An inventory service, InventoryIntegrationService, is the direction, and its contract is published in the inventory reference. Its routes do not answer on https://api.zentail.com yet, so a call to /v2/inventory fails. Until they do, report stock with the v1 route in step 6. This page will move to the /v2/inventory routes when they are reachable.

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": "warehouses_bound",
"state": "CHECK_STATE_PASS",
"message": "1 warehouse(s) bound to this integration",
"source": "CHECK_SOURCE_ZENTAIL"
}
]
}

Look for warehouses_bound first. 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 read the warehouse mapping with GET /v1/warehouses. Stock updates key on the identifier it returns, and v1 is the only route to it today.

Terminal window
curl -X GET "https://api.zentail.com/v1/warehouses" \
-H "Authorization: <access token>" \
-H "Accept: application/json"
{
"results": [
{
"warehouseId": 3, // Zentail's own numeric id
"warehouseUniqueId": "Warehouse 0001", // your id, as the seller mapped it
"name": "Main Warehouse",
"canUpdateInventory": true, // false when another service owns this stock
},
],
}

Store warehouseUniqueId against your own warehouse record. When canUpdateInventory is false, another service owns that warehouse’s stock and Zentail ignores your updates for it, so skip it in step 6. GET /v1/warehouse/{id} returns one warehouse’s name and address.

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 /v1/inventory. This is the route to call today. InventoryIntegrationService is not yet reachable on https://api.zentail.com, as the introduction explains.

One request carries up to 50 entries in products. Name the warehouse with the warehouseUniqueId from step 1.

Terminal window
curl -X POST "https://api.zentail.com/v1/inventory" \
-H "Authorization: <access token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"validAsOf": "2026-03-27T15:53:28-04:00",
"products": [
{
"SKU": "SKU_00000",
"onhand_quantity": 1389,
"warehouseUniqueId": "Warehouse 0001"
}
]
}'

Send validAsOf when you can. It states when the counts were true, which lets Zentail order two updates that arrive out of order. A SKU that does not exist in the seller’s account is created with the quantity you sent.

Send exactly one quantity field per entry:

Field What Zentail does with it
onhand_quantity Sets the physical count. Zentail subtracts what its open orders have reserved and offers the rest for sale
quantity Sets the quantity available for sale to exactly this number, with no reservation subtracted
delta_quantity Adds this number to the quantity Zentail currently shows

Send onhand_quantity if your platform does not reserve stock for open orders, and let Zentail do that subtraction. Send quantity only if your platform has already taken reservations out.

To read levels back, use GET /v1/inventory for a list or GET /v1/inventory/{SKU} for one SKU.

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

API changelog · Built 0c509dd3