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.
What you’ll build
Section titled “What you’ll build”A poller that, every few minutes:
- reads the work Zentail wants shipped,
- records the id you gave each piece on your side,
- reports each package as it ships, or declines what you cannot ship,
- pulls back anything Zentail cancels,
- and raises an alert when something is holding a shipment up.
Before you start
Section titled “Before you start”- 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:selfscope. Every/v2/shippingroute 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 asinventory:warehouse:selfinstead. 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.
1. Check you are wired up
Section titled “1. Check you are wired up”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.
2. Poll the work you owe
Section titled “2. Poll the work you owe”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:
shipTois free text. Zentail copies it through from the channel without validating it, socountryandregionare not guaranteed ISO codes. A fulfillment order with no address at all has noshipTo.shippingPrice,shippingTaxanddiscountare order-level. A fulfillment order covering part of an order carries the whole figure, so do not sum them across siblings.- Each line’s
unitPriceis what the buyer paid per unit, andtaxis for the whole line. - Some fields are always empty today.
shipByTsandgiftMessageare never set, and a line’sshippedQuantityandcancelledQuantityare always zero, becausequantityis already what you still owe. Prioritise onassignedTs.
To fetch one fulfillment order outside the queues, see Reconciling after a crash.
3. Acknowledge what you have created
Section titled “3. Acknowledge what you have created”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_shipwith noexternalOrderId. 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.
4. Ship it, or reject it
Section titled “4. Ship it, or reject it”Report a package
Section titled “Report a package”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:
linesnames what is in the package, bylineItemIdandquantity. Leave it out to ship everything the fulfillment order still owes, which is the common case.carrieris free text, shown to the buyer beside the tracking number.shippedTsdefaults to the time the request arrived. Send it when catching up on a backlog.trackingUrlis accepted but not stored.
shippingCost and handlingCost are separate on purpose, and unset means unknown, not
zero. Postage is the carrier’s price; a handling fee is yours. Reading an unset cost as zero
silently inflates margin.
Legacy (v1)
Report packages with
POST /v1/salesOrder/shipments.
v2 shipments are posted through this same endpoint, so a package lands in the same place
either way. Don’t report one package through both.
Each entry in packages requires orderNumber (a number here, though order responses return
it as a string), fulfillmentPackageId, carrier, tracking and service_level. Only
service_level is snake case; the rest are camel case. Send warehouseUniqueId as well. You may leave it out only when exactly one warehouse is
mapped; otherwise the package is rejected.
{ "packages": [ { "orderNumber": 1000005, "fulfillmentPackageId": "ShipCo #123456", "warehouseUniqueId": "Warehouse 0001", "carrier": "USPS", "tracking": "9400111899223197428490", "service_level": "First Class", "products": [{ "sku": "TESTSKU1", "quantity": 2 }] } ]}- Leaving
productsout does not send an empty package. Zentail fills it with every unshipped unit on that order routed to that warehouse. Sendproductswhenever the package holds less than that. Only packages withproductsare passed on to the sales channel. - A
200does not mean every package was accepted. Skipped packages are listed in the response’serrorsarray. Retry only those, since retrying an accepted package creates a second one. - Send tracking once, and get it right. Most sales channels do not accept a correction.
Add later packages to the same order with the same call as the rest of it ships.
Reject what you cannot ship
Section titled “Reject what you cannot ship”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.
5. Handle cancellations
Section titled “5. Handle cancellations”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.
A cancellation line is not a fulfillment order line. On a fulfillment order, quantity
means “routed to your warehouse”. On a cancellation it means “pull this many back”. Don’t
reuse one line parser for both.
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.
Legacy (v1)
v1 has no cancel queue. Include CANCELLED in the status filter on your
GET /v1/salesOrder poll, and stop any of those orders you have not yet shipped. To find a
partial cancellation, read cancelQuantity on each line item. There is no call to confirm a
cancellation.
6. Raise and resolve alerts
Section titled “6. Raise and resolve alerts”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.
Resolve only alerts you raised. The contract scopes a resolve to the alerts your integration raised, but that is not enforced yet. Today a resolve clears an open alert of that type on the order whoever raised it, as long as the order is routed to one of your warehouses.
Legacy (v1)
Raise or resolve an alert on a whole order with
POST /v1/salesOrder/{orderNumber}/alert.
The body takes a type and a resolved flag, both required. Send resolved: false with a
message to raise one, and resolved: true with a resolution to clear it.
Reconciling after a crash
Section titled “Reconciling after a crash”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.
See also
Section titled “See also”- The shipping reference, which documents every operation in this profile, including the v1 routes.
- Pagination, Errors and Throttle limits.
If anything here is unclear, email [email protected].
API changelog · Built 0c509dd3