ConfirmShipments reports packages that have shipped, draining the shipped quantities from ListNeedToShip.
You call thissales:orders:fulfillment:self
const url = 'https://api.zentail.com/v2/shipping/shipments';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"shipments":[{"fulfillmentOrderId":"example","externalShipmentId":"example","carrier":"example","trackingNumber":"example","trackingUrl":"example","serviceLevel":"example","shippedTs":"2026-04-15T12:00:00Z","lines":[{"lineItemId":"example","quantity":1}],"shippingCost":{"amount":"example","currencyCode":"example"},"handlingCost":{"amount":"example","currencyCode":"example"}}]}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://api.zentail.com/v2/shipping/shipments \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "shipments": [ { "fulfillmentOrderId": "example", "externalShipmentId": "example", "carrier": "example", "trackingNumber": "example", "trackingUrl": "example", "serviceLevel": "example", "shippedTs": "2026-04-15T12:00:00Z", "lines": [ { "lineItemId": "example", "quantity": 1 } ], "shippingCost": { "amount": "example", "currencyCode": "example" }, "handlingCost": { "amount": "example", "currencyCode": "example" } } ] }'Idempotent on external_shipment_id: replaying a shipment is a no-op, so a retry after a timeout can never double-ship.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”object
One entry per package. Required and server-capped: an empty list, or one over the cap, fails the whole request rather than answering per entry.
object
Which fulfillment order this package is against. One package cannot span two fulfillment orders, so a consignment covering both halves of a split order is two Shipments.
Your identifier for this package. Idempotency key: a repeat is a no-op.
Who is carrying it, as you name them — free text, not a Zentail enum. Shown to the buyer and to support alongside the tracking number.
Required. A package sent without one is refused rather than recorded, because Zentail drops an untracked package silently: accepting it would report success for a shipment that was never stored, leaving the units owed with nothing saying why.
Not currently stored. Zentail tracks by carrier and tracking number and has nowhere to put a per-package URL, so anything sent here is accepted and discarded. Measured against sales-orders on 2026-09-21.
Required, and not defaulted. The shipping speed you actually used, in your own vocabulary — Zentail will not substitute a value, because one guess for every fulfiller hides which of them reported nothing. Send a placeholder of your own if you have no real level to report.
When the package was handed to the carrier. Omit it and Zentail stamps the time the request arrived, which is the right behaviour for a confirmation sent immediately; send it when you are catching up on a backlog.
What is in the package. Omit to ship everything the fulfillment order still owes — the common case. Naming lines ships those quantities only; a line_item_id not on the order, a quantity below one, or quantities summing past what is owed fails this package and leaves the units owed.
object
Which line of the fulfillment order, echoed from FulfillmentOrderLine. A SKU is not accepted here: one SKU can appear on two lines.
How many units of that line are in this package. At least one, and never more than the line still owes — across the whole request, so naming one line twice is summed before it is checked.
Money is an amount and the currency it is in.
The amount is a decimal string, not a float or units+nanos. Money in this contract crosses into Shopify’s MoneyBagInput, which is itself a decimal string, so a string maps across untouched — no scaling and no rounding at the edge, which is where money bugs come from. Follows the Money already in api-proto’s listing contract rather than google.type.Money, for that reason.
currency_code travels with every amount rather than sitting once on the order. Multi-currency is real (etp has purchase_orders.non_standard_currencies), and a silently wrong currency is worse than a wrong amount: the number still looks plausible.
object
Decimal string, e.g. “13.00”. Negative for a credit.
ISO 4217, e.g. “USD”.
Money is an amount and the currency it is in.
The amount is a decimal string, not a float or units+nanos. Money in this contract crosses into Shopify’s MoneyBagInput, which is itself a decimal string, so a string maps across untouched — no scaling and no rounding at the edge, which is where money bugs come from. Follows the Money already in api-proto’s listing contract rather than google.type.Money, for that reason.
currency_code travels with every amount rather than sitting once on the order. Multi-currency is real (etp has purchase_orders.non_standard_currencies), and a silently wrong currency is worse than a wrong amount: the number still looks plausible.
object
Decimal string, e.g. “13.00”. Negative for a credit.
ISO 4217, e.g. “USD”.
Examplegenerated
{ "shipments": [ { "fulfillmentOrderId": "example", "externalShipmentId": "example", "carrier": "example", "trackingNumber": "example", "trackingUrl": "example", "serviceLevel": "example", "shippedTs": "2026-04-15T12:00:00Z", "lines": [ { "lineItemId": "example", "quantity": 1 } ], "shippingCost": { "amount": "example", "currencyCode": "example" }, "handlingCost": { "amount": "example", "currencyCode": "example" } } ]}Responses
Section titled “ Responses ”A successful response.
object
One result per shipment sent. Match on external_shipment_id rather than on position, and read every entry — one package failing does not fail the others, and the units it covered are still owed.
object
The fulfillment order this package was against, echoed back.
Your own id for the package, echoed back. This is what to match a result on: one call may carry several packages for the same fulfillment order.
True when the package is recorded, including when it already was — a replay reports success with already_recorded set. False is always a real failure, and the units it covered are still owed.
Why it failed, in prose, for logs and support. Empty on success. No error code accompanies it, so log the string rather than branching on it.
True when this shipment was already recorded. Treat as success.
Examplegenerated
{ "results": [ { "fulfillmentOrderId": "example", "externalShipmentId": "example", "success": true, "errorMessage": "example", "alreadyRecorded": true } ]}default
Section titled “default”An unexpected error response.
object
object
Examplegenerated
{ "code": 1, "message": "example", "details": [ { "@type": "example" } ]}