Skip to content

Shipping integration guide

This is the ordered path for a shipping integration: a 3PL (third-party logistics provider), a warehouse management system, or shipping software that buys the label for a Zentail seller’s orders and reports the tracking back.

Zentail tells you what it wants through two queues. One holds work to ship, and one holds work to pull back. If a fulfillment order is in a queue, Zentail wants something done about it, and confirming is what removes it. There is no change feed to diff and no status to interpret, so an integration that drains both queues is correct by construction.

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

A poller that, every few minutes:

  1. reads the work Zentail wants shipped,
  2. records the id you gave each piece on your side,
  3. reports each package as it ships, or declines what you cannot ship,
  4. pulls back anything Zentail cancels,
  5. and raises an alert when something is holding a shipment up.
  • 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.
  • Your token needs the sales:orders:fulfillment:self scope. Every /v2/shipping route enforces it. It also admits the v1 order, shipment and alert routes, so an existing v1 integration already holds it. The v1 warehouse routes in step 1’s aside take a warehouse scope such as inventory:warehouse:self instead. See Scopes.
  • A warehouse has to name your integration as its fulfiller, with a shipping identifier set. The seller does this when they install your application, mapping each Zentail warehouse onto the warehouse id your own system uses. Until one is mapped, nothing is routed to you and both queues stay empty.

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

The /v2/shipping routes send every 64-bit integer as a JSON string, so fulfillmentOrderId and orderNumber arrive as "10012345". Errors come back as a google.rpc.Status body, and v1 errors differ; see Errors.

Request GET /v2/shipping/status. It returns a list of diagnostic checks: whether warehouses are bound, whether each queue is being drained, and whether anything has sat in one too long. It is the cheapest call to run first, and the only one that answers before any work exists.

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

Each check carries:

Field What it is
name A stable identifier. Match on this, never on the message.
state CHECK_STATE_PASS, CHECK_STATE_WARN or CHECK_STATE_FAIL.
message What is wrong and what would fix it, for a person.
source CHECK_SOURCE_ZENTAIL when Zentail observed it, CHECK_SOURCE_INTEGRATION when your integration reported it.
warehouseUniqueId The warehouse the check is about, when it is about one.

Look for warehouses_bound first. It fails until a warehouse names your integration as its fulfiller, and its message says so.

Request GET /v2/shipping/need_to_ship. It returns the fulfillment orders you owe a shipment on: work routed to your warehouses and not yet shipped or cancelled.

Parameter Description
cursor Leave it out for the first page. After that, pass the response’s nextCursor.
pageSize Omit it for the default of 100. The cap is 500, and a larger value is clamped to it.

Keep requesting until nextCursor comes back empty. There is no time filter, on purpose: a queue is drained by confirming, never by advancing a clock, so you cannot skip past work you failed to finish.

Read lines fresh on every poll, and never cache it. New work and re-routed work look the same here. A partial re-route lowers a line’s quantity rather than removing the fulfillment order, so what you read last poll may not be what you owe now.

A page looks like this, trimmed to one fulfillment order that has not been acknowledged yet:

{
"orders": [
{
"fulfillmentOrderId": "88120045",
"orderNumber": "10012345",
"status": "FULFILLMENT_ORDER_STATUS_NEW",
"warehouseUniqueId": "Warehouse 0001",
"orderedTs": "2026-09-22T13:40:02Z",
"assignedTs": "2026-09-22T13:41:10Z",
"shipTo": {
"name": "Jane Doe",
"line1": "100 Congress Ave",
"city": "Austin",
"region": "TX",
"postalCode": "78701",
"country": "US"
},
"requestedServiceLevel": "Standard",
"buyerName": "Jane Doe",
"lines": [
{
"lineItemId": "88001",
"sku": "TSHIRT-RED-M",
"title": "T-shirt, red, medium",
"quantity": 2,
"unitPrice": { "amount": "12.50", "currencyCode": "USD" },
"tax": { "amount": "2.06", "currencyCode": "USD" }
}
],
"shippingPrice": { "amount": "5.99", "currencyCode": "USD" },
"totalPayment": { "amount": "33.05", "currencyCode": "USD" }
}
],
"nextCursor": ""
}

fulfillmentOrderId, orderNumber and lineItemId are JSON strings, so parse them as integers. A line’s quantity is a plain number. There is no externalOrderId yet, because this work has not been acknowledged.

Each fulfillment order carries:

Field What it is
fulfillmentOrderId Zentail’s id for this work, and the handle for every write. Stable for its life.
orderNumber The customer order it came from. Display and correlation only.
externalOrderId Your id for it, once you have acknowledged it in step 3.
warehouseUniqueId The warehouse it is routed to, in your own namespace.
assignedTs When it was routed to you. Age your queue on this, not on orderedTs, which is when the buyer ordered.
shipTo, buyerName Who the label goes to, and who bought it. They differ on a gift or a business delivery; address from shipTo.
requestedServiceLevel The shipping speed the buyer paid for, as Zentail’s standard level. Map it onto one of your own services.
lines lineItemId, sku, title, and the quantity routed to your warehouse.
openAlerts Alerts you currently have open on it. See step 6.
shippingPrice, shippingTax, discount, totalPayment Money on the order: an amount as a decimal string, never a float, plus an ISO 4217 currencyCode.

A few fields need care:

  • shipTo is free text. Zentail copies it through from the channel without validating it, so country and region are not guaranteed ISO codes. A fulfillment order with no address at all has no shipTo.
  • shippingPrice, shippingTax and discount are order-level. A fulfillment order covering part of an order carries the whole figure, so do not sum them across siblings.
  • Each line’s unitPrice is what the buyer paid per unit, and tax is for the whole line.
  • Some fields are always empty today. shipByTs and giftMessage are never set, and a line’s shippedQuantity and cancelledQuantity are always zero, because quantity is already what you still owe. Prioritise on assignedTs.

To fetch one fulfillment order outside the queues, see Reconciling after a crash.

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

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

Send at most 50 per call. An empty list, or one over the cap, fails the whole request.

Acknowledging does not drain need_to_ship, and it is not required before shipping. It is still worth doing, for two reasons:

  • It makes a crash recoverable. If you die between creating the work and telling Zentail, the fulfillment order is still in need_to_ship with no externalOrderId. That gap tells you to reconcile rather than create a duplicate.
  • It is what puts the work into the cancel queue. Only acknowledged work can appear in step 5. If you never told Zentail you created it, no cancel request reaches you when Zentail cancels it: the units simply leave need_to_ship. Acknowledge anything you have started work on.

externalOrderId is unique per integration. Zentail rejects a duplicate rather than recording it twice, which makes a retry safe.

Read each entry in results, matched on fulfillmentOrderId, rather than the HTTP status: one failing does not fail the rest. Each carries success, errorMessage and alreadyAcknowledged. alreadyAcknowledged: true is a replay, not a conflict. Treat it as success. Log errorMessage, but do not branch on its wording.

v1 has no acknowledgement, so this step has no legacy route.

Report each package as it ships with POST /v2/shipping/shipments. It drains the shipped quantities from need_to_ship. Send at most 50 shipments per call, and they need not belong to the same fulfillment order.

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

Four fields are refused when empty, so send all four on every package:

Field Why
fulfillmentOrderId The fulfillment order the package is against. One package cannot span two.
externalShipmentId Your id for the package, and the idempotency key. Without it, a retry becomes a second package.
trackingNumber Zentail would drop an untracked package silently, so it is refused up front instead.
serviceLevel The speed you actually used, in your own words. It is not defaulted; send a placeholder if you have none.

Replaying a shipment is a no-op, so a retry after a timeout can never double-ship. The result’s alreadyRecorded: true is how you see that, and it is a success. Match results on externalShipmentId. A failed entry leaves its units owed.

{
"results": [
{
"fulfillmentOrderId": "88120045",
"externalShipmentId": "PKG-000918",
"success": true,
"alreadyRecorded": false
}
]
}

The optional fields:

  • lines names what is in the package, by lineItemId and quantity. Leave it out to ship everything the fulfillment order still owes, which is the common case.
  • carrier is free text, shown to the buyer beside the tracking number.
  • shippedTs defaults to the time the request arrived. Send it when catching up on a backlog.
  • trackingUrl is accepted but not stored.

Decline work with POST /v2/shipping/fulfillment_orders/reject. Zentail then reroutes it or surfaces it to the seller, and it leaves need_to_ship. Send at most 50 rejections per call.

Each rejection needs a reason, and an unset one is refused:

Reason When
REJECTION_REASON_OUT_OF_STOCK You stock the SKU but hold too few to ship this.
REJECTION_REASON_DAMAGED The units are there but not shippable.
REJECTION_REASON_UNDELIVERABLE_ADDRESS Your carrier refuses the address, or it is incomplete or missing.
REJECTION_REASON_SKU_NOT_FOUND You do not stock the SKU at all, so the catalogue or routing needs fixing.
REJECTION_REASON_OTHER Anything else. Say what in detail.

detail is free text shown to the seller beside the reason.

Always leave out lines. Partial rejection is not supported yet, and naming any line fails the entry. Reject the whole fulfillment order, or none of it.

v1 has no way to decline work, so this step has no legacy route.

Request GET /v2/shipping/need_to_cancel. It returns acknowledged fulfillment orders that Zentail now wants pulled back. It pages exactly like need_to_ship, with cursor and pageSize and no time filter.

Each entry is a cancellation request, carrying fulfillmentOrderId, your externalOrderId, the warehouseUniqueId, a requestedTs, the lines to pull back, and a reason: CANCELLATION_REASON_REROUTED, CANCELLATION_REASON_BUYER_CANCELLED, CANCELLATION_REASON_CHANNEL_CANCELLED or CANCELLATION_REASON_MERCHANT_CANCELLED.

Every request today is a total withdrawal: each line cancels at its full routed quantity.

Once you have pulled the work back, confirm it with POST /v2/shipping/fulfillment_orders/confirm_cancellations. Send at most 50 confirmations per call, and always leave out lines: partial confirmation is not supported yet, and naming a line fails the entry.

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

If you only pulled part of it back, don’t confirm yet. Confirming says the whole request was cancelled.

If the units have already gone, still confirm, with alreadyShipped: true on that entry. Then report the package through step 4 as normal. Zentail needs the difference to keep the customer order truthful, or it would show a cancellation that did not happen.

A request stays in the queue until you confirm it, so one you already handled means the confirmation did not land. alreadyRecorded: true on a result is a success.

An alert is the same alert a seller already sees on a customer order. Raise one to explain a delay you are working through. An alert is not a substitute for rejecting: raise one when you still intend to ship, and reject when you do not.

Raise alerts with POST /v2/shipping/fulfillment_orders/alerts, up to 50 per call. Each takes a fulfillmentOrderId, a type, a message shown to the seller, and optionally a lineItemId to scope it to one line.

Type When
ALERT_TYPE_FULFILL Something blocks fulfilment, but you have not given up on it.
ALERT_TYPE_LATE_SHIPMENT It will miss, or has missed, the ship-by deadline.
ALERT_TYPE_LOST It shipped, but the package is lost in transit.
ALERT_TYPE_OTHER Anything else. Put the specifics in message.

Raising is idempotent. Zentail never opens a second alert of the same type on the same order, so you can re-raise a condition on every poll without keeping your own record. alreadyOpen: true is the normal answer for a condition that has not cleared, and it is a success.

Clear one with POST /v2/shipping/fulfillment_orders/alerts/resolve, up to 50 per call. Each takes a fulfillmentOrderId, a type, and a resolution saying why, which the seller sees. alreadyResolved: true means nothing was open to clear, which is what a retry sees. It is a success.

Fetch one fulfillment order, whatever queue it is or is not in, with GET /v2/shipping/fulfillment_order. Use it when you created work on your side and are not sure Zentail heard about it.

Pass exactly one of fulfillmentOrderId or externalOrderId. orderNumber is not accepted, because one customer order can give you two fulfillment orders.

This is a lookup, not a queue. An order it returns may still owe a shipment, and only need_to_ship says so.

A fulfillment order that does not exist and one at another integration’s warehouse both answer NotFound. They are deliberately not told apart, so the API never confirms that another integration’s work exists.

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

API changelog · Built 0c509dd3