Skip to main content

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:

ConceptWhat it is
Shipment itemsA 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.
PhaseEvery 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.
Rolluporder.shipment_status is derived from the item math, never set directly. Every write on this page recomputes it.
The carrier code must be registered

carrier 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.

Request Schema (application/json)
No request body required
GET/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
https://<your domain>/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments
Sample Of Response
{
"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.

Request Schema (application/json)
Field NameField TypeRequired
itemsarray of objectYes
Field NameField TypeRequired
order_item_idintegerYes
qtyintegerYes
carrierstringYes
tracking_numberstringNo
notifyCustomerbooleanNo
POST/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments
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
Sample Of Response
{
"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:

RequestBehaviourlabelCreated
tracking_number suppliedStored as-is. No carrier call.false
Omitted, carrier implements createLabelA 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 createLabelThe 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:

  • items is empty or carrier is missing.
  • carrier is not a registered carrier code.
  • Any order_item_id does not belong to the order.
  • Any item has no_shipping_required = true (digital items can never be in a shipment).
  • Any qty is not a positive integer, or exceeds the remaining unshipped quantity. Quantities inside canceled shipments are released back to the pool and become shippable again.
Business errors return 500

This 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.

Request Schema (application/json)
Field NameField TypeRequired
carrierstringNo
tracking_numberstringNo
PATCH/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60
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
Sample Of Response
{
"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.

Request Schema (application/json)
No request body required
POST/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/markDelivered
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
Sample Of Response
{
"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.

Request Schema (application/json)
No request body required
POST/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/cancel
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
Sample Of Response
{
"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.

Request Schema (application/json)
No request body required
DELETE/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/label
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
https://<your domain>/api/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60/label
Sample Of Response
{
"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_url is 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.

Voiding a shipment past the shipped phase returns 500, not 400

Carriers 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:

ValueMeaning
pendingNothing has shipped yet.
partially_shippedSome, but not all, quantity has shipped.
shippedEvery shippable item is fully shipped.
partially_deliveredSome quantity has been delivered.
deliveredEvery shippable item is delivered. Also the value for an all-digital order, which short-circuits.
partially_canceledSome quantity is in canceled shipments and nothing has shipped or been delivered.
canceledThe 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:

CodePhaseNotes
shippedshippedWhere every new shipment starts.
delivereddeliveredTerminal.
canceledcanceledTerminal.

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.

Request Schema (application/json)
Field NameField TypeRequired
order_idstringNo
POST/api/deliveries
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/deliveries
Sample Of Response
{
"data": {
"order_id": 18,
"updated_count": 2
}
}
order_id here is the numeric primary key

Unlike 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.

Request Schema (application/json)
Field NameField TypeRequired
carrierstringNo
tracking_numberstringNo
PATCH/api/orders/2f8a1c40-9d31-4c5b-8f6e-3b1a7d0e5c92/shipments/a1d5f7b2-3c4e-4d8a-9f01-6b2c3d4e5f60
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
Sample Of Response
{
"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.