Shipment API
Overview
An order can have many shipments. A shipment is a set of (order_item, qty) pairs handed to one carrier, so a five-item order can be fulfilled as one shipment, five shipments, or anything in between.
Three ideas drive every endpoint on this page:
| Concept | What it is |
|---|---|
| Shipment items | A shipment always carries an explicit item list. There is no "ship the whole order" shortcut — the caller names each order_item_id and the qty going in the box. |
| Phase | Every shipment status maps to one of three hardcoded phases: shipped, delivered, canceled. There is no pending phase — a shipment row exists only because something actually shipped. |
| Rollup | order.shipment_status is derived from the item math, never set directly. Every write on this page recomputes it. |
carrier code must be registeredcarrier is not a free-text label. It must match the code of a carrier registered with registerCarrier(...) from a module's bootstrap.ts; an unrecognised code fails with Unknown carrier '<code>'. Install or register the carrier extension first. Core registers exactly one out of the box — custom ("Custom / Other"), a capability-free fallback for shipping without a carrier integration.
Endpoints
List An Order's Shipments
Returns every shipment on the order, each with its items array embedded, ordered by created_at ascending. Returns an empty array when the order has no shipments or the id does not resolve.
{id} is the order uuid.
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments
fetch('https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
}
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": [
{
"shipment_id": 41,
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60",
"shipment_order_id": 18,
"carrier": "custom",
"tracking_number": "1Z999AA10123456784",
"status": "shipped",
"shipped_at": "2025-11-04T09:12:44.000Z",
"delivered_at": null,
"canceled_at": null,
"label_url": null,
"label_format": null,
"carrier_shipment_id": null,
"carrier_metadata": null,
"tracking_url": null,
"created_at": "2025-11-04T09:12:44.000Z",
"updated_at": "2025-11-04T09:12:44.000Z",
"items": [
{
"shipment_item_id": 77,
"uuid": "b2e6a8c3-4d5f-4e9b-a012-7c3d4e5f6071",
"shipment_id": 41,
"order_item_id": 55,
"qty": 2,
"created_at": "2025-11-04T09:12:44.000Z"
}
]
}
]
}
Create A Shipment
Creates one shipment for a subset of the order's items. {id} is the order uuid.
| Field Name | Field Type | Required | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| items | array of object | Yes | |||||||||
| |||||||||||
| carrier | string | Yes | |||||||||
| tracking_number | string | No | |||||||||
| notifyCustomer | boolean | No | |||||||||
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments
fetch('https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"shipment": {
"shipment_id": 41,
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60",
"shipment_order_id": 18,
"carrier": "custom",
"tracking_number": "1Z999AA10123456784",
"status": "shipped",
"shipped_at": "2025-11-04T09:12:44.000Z",
"delivered_at": null,
"canceled_at": null,
"label_url": null,
"label_format": null,
"carrier_shipment_id": null,
"carrier_metadata": null,
"tracking_url": null
},
"items": [
{
"order_item_id": 55,
"qty": 2
}
],
"labelCreated": false
}
}
order_item_id is the numeric order_item.order_item_id, not a uuid. Read the candidate items from the order's GraphQL items before building the payload.
Tracking number, or a purchased label
The presence of tracking_number decides whether the carrier's API is called:
| Request | Behaviour | labelCreated |
|---|---|---|
tracking_number supplied | Stored as-is. No carrier call. | false |
Omitted, carrier implements createLabel | A label is purchased before the transaction opens. The returned tracking number, label URL, label format, carrier shipment id, metadata and tracking URL are written onto the shipment. | true |
Omitted, carrier has no createLabel | The shipment is created with tracking_number: null. This is the normal path for the built-in custom carrier. | false |
If the transaction fails after a label was purchased, EverShop calls the carrier's voidLabel as a compensating action so no orphan tracking number is left behind.
notifyCustomer defaults to true; it is forwarded on the shipment_created event, where the shipment-confirmation email subscriber reads it. Send false to create a shipment silently.
Validation
The item list is validated twice — once before any write, and again under a per-order advisory lock inside the transaction, so two concurrent requests cannot over-allocate the same item. A request is rejected when:
itemsis empty orcarrieris missing.carrieris not a registered carrier code.- Any
order_item_iddoes not belong to the order. - Any item has
no_shipping_required = true(digital items can never be in a shipment). - Any
qtyis not a positive integer, or exceeds the remaining unshipped quantity. Quantities insidecanceledshipments are released back to the pool and become shippable again.
500This handler maps every thrown error to 500 Internal Server Error with the message in error.message — including validation failures such as an unknown carrier or an over-allocated quantity. Only payload-schema violations (missing items, missing carrier, wrong types) return 400. Read error.message, not just the status.
Update A Shipment
Updates the carrier and/or tracking number on a single shipment, addressed by its own uuid. Fields are applied only when present and of type string; sending neither is rejected.
| Field Name | Field Type | Required |
|---|---|---|
| carrier | string | No |
| tracking_number | string | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60
fetch('https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"shipment_id": 41,
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60",
"shipment_order_id": 18,
"carrier": "custom",
"tracking_number": "1Z999AA10123456799",
"status": "shipped",
"shipped_at": "2025-11-04T09:12:44.000Z",
"delivered_at": null,
"canceled_at": null
}
}
Errors: 400 Invalid shipment id, 400 Nothing to update; provide carrier and/or tracking_number.
A Shipment information updated entry is added to the order's activity log. This endpoint does not re-validate the carrier code against the registry, and it never touches the item list or the status.
Mark A Shipment Delivered
Advances one shipment into the delivered phase and recomputes the order rollup. Takes no request body.
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/markDelivered
fetch('https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/markDelivered', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60"
}
}
Sets delivered_at on first entry into the phase, emits shipment_status_changed and shipment_delivered. delivered is terminal — a second call, or a call on a canceled shipment, fails with Cannot transition shipment from phase ... to phase delivered.
Errors: 400 Invalid shipment id; 500 with the transition message on an illegal phase change.
Cancel A Shipment
Moves one shipment into the canceled phase. Takes no request body.
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/cancel
fetch('https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/cancel', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60"
}
}
Sets canceled_at, releases the shipment's quantities back to the unshipped pool, and recomputes order.shipment_status. A delivered shipment cannot be canceled.
Canceling every shipment does not cancel the order — use POST /api/orders/{id}/cancel for that. See the Order API.
Errors: 400 Invalid shipment id; 500 with the transition message on an illegal phase change.
Void A Shipping Label
Voids a previously purchased label through the carrier's voidLabel method, then clears label_url and label_format on the shipment. tracking_number is deliberately kept as a record of the original purchase.
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/label
fetch('https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/label', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
}
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60"
}
}
The call is rejected with 400 when any of these hold:
- The shipment does not exist.
- It has no purchased label (
label_urlis null). - It has no carrier code, its carrier is not registered, or that carrier does not implement
voidLabel. - It has a label but no tracking number (an inconsistent state the service refuses to act on).
Anything else — including a carrier-side failure — surfaces as 500. On success a shipment_label_voided event fires and the order activity log records the void.
shipped phase returns 500, not 400Carriers refuse to void a committed label, and the service throws
Cannot void label for shipment <uuid> — already in terminal phase '<phase>'. That
message is not among the alternatives the handler matches when choosing the status
code, so the call surfaces as a 500 rather than the 400 you would expect. Treat it
as a client-side condition regardless of the status code.
Order Shipment Status Is Derived
order.shipment_status is a cached rollup, recomputed after every shipment write. Do not write it directly.
The math runs per shippable order item (digital items are excluded), comparing ordered quantity against quantity in shipped-, delivered- and canceled-phase shipments, and resolves to one of seven values:
| Value | Meaning |
|---|---|
pending | Nothing has shipped yet. |
partially_shipped | Some, but not all, quantity has shipped. |
shipped | Every shippable item is fully shipped. |
partially_delivered | Some quantity has been delivered. |
delivered | Every shippable item is delivered. Also the value for an all-digital order, which short-circuits. |
partially_canceled | Some quantity is in canceled shipments and nothing has shipped or been delivered. |
canceled | The order itself is canceled, or every shippable item sits in canceled shipments. |
Shipping progress wins over cancellation: an item that was canceled and then re-shipped reads as shipped. partially_shipped, partially_delivered and partially_canceled exist only at the order level — no individual shipment can hold them.
The rollup then feeds order.status through the payment-status / shipment-status mapping. See Order Status Management.
Shipment Statuses
Core registers three statuses, each bound to a phase:
| Code | Phase | Notes |
|---|---|---|
shipped | shipped | Where every new shipment starts. |
delivered | delivered | Terminal. |
canceled | canceled | Terminal. |
Extensions may register additional statuses with registerShipmentStatus, but each one must pick an existing phase — the three phases are fixed in code. Same-phase transitions (for example a custom in_transit to a custom out_for_delivery, both shipped) are always permitted.
Back-Compatible Endpoints
These two routes predate the multi-shipment model. They still work, but new integrations should use the per-shipment endpoints above.
Mark A Whole Order Delivered
Sweeps every shipment on the order and tries to advance each to delivered, skipping the ones that reject on the phase check. updated_count reports how many actually moved.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/deliveries
fetch('https://<your domain>/api/deliveries', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"order_id": 18,
"updated_count": 2
}
}
order_id here is the numeric primary keyUnlike every other order route, this handler looks the order up by order.order_id — the numeric column — not by uuid. The payload schema types it as string purely for legacy form posts. Prefer POST /api/shipments/{shipment_uuid}/markDelivered, which is uuid-keyed and targets exactly one shipment.
Errors: 400 Invalid order id, 400 No shipments to mark delivered.
Update A Shipment Through The Order
The order-scoped form of PATCH /api/shipments/{shipment_uuid}. Both path segments are uuids. Unlike the newer route it accepts an empty body, in which case it writes carrier and tracking_number as undefined.
| Field Name | Field Type | Required |
|---|---|---|
| carrier | string | No |
| tracking_number | string | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60
fetch('https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"shipment_id": 41,
"uuid": "a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60",
"shipment_order_id": 18,
"carrier": "custom",
"tracking_number": "1Z999AA10123456799",
"status": "shipped"
}
}
Errors: 400 Invalid order id, 400 Invalid shipment id.
Reading Shipment Data With GraphQL
Shipments are exposed on the Order type in GraphQL, which is the right surface for storefront and admin reads. See the GraphQL API documentation.
Related Documentation
- Multi-Shipment and Fulfillment — the model behind these endpoints.
- Carrier Development — writing a carrier with
createLabel,voidLabeland tracking. - Order API — order creation, cancellation and status.
- Shipping Provider API — configuring the methods a customer can pick at checkout.
- Package API — the parcel dimensions that feed carrier label and rating calls.