Package API
Overview
A package is an admin-managed box or envelope size: a name, three dimensions, and an optional tare weight. Products point at one through product.package_id, and that choice is what gives every downstream shipping calculation a parcel to reason about.
| Field | Meaning |
|---|---|
name | Unique label shown in the product form, for example "Standard Box". |
length / width / height | Outer dimensions in the store's dimension unit (the dimensionUnit admin setting). Length and width must be greater than 0; height may be 0 for a flat envelope. |
weight | Tare — the weight of the empty package, in the store's weight unit (the weightUnit admin setting). Optional, defaults to 0. This is not the weight of the goods. |
is_default | Exactly one package is the default, preselected when a new product is created. Enforced by a unique partial index, not just by the service. |
A fresh install seeds one package — Standard Box, 30 × 25 × 10, tare 0, default — so product creation never dead-ends. Merchants rename or edit it rather than starting from nothing.
length, width, height and weight are Postgres decimal columns, so responses carry them as strings ("30.00"). Requests may send either numbers or numeric strings; the handler coerces with Number() before validating.
Endpoints
Create A Package
| Field Name | Field Type | Required |
|---|---|---|
| name | string | Yes |
| length | number or string | Yes |
| width | number or string | Yes |
| height | number or string | Yes |
| weight | number or string | No |
| is_default | boolean | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/packages
fetch('https://<your domain>/api/packages', {
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": {
"package_id": 4,
"uuid": "7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34",
"name": "Large Box",
"length": "45.00",
"width": "35.00",
"height": "25.00",
"weight": "0.4500",
"is_default": false,
"created_at": "2025-11-04T09:12:44.000Z",
"updated_at": "2025-11-04T09:12:44.000Z"
}
}
weight defaults to 0 and is_default to false. Sending is_default: true unsets the current default inside the same transaction, so there is never a moment with two defaults or none.
Beyond the payload schema, the service enforces the same rules as the table's CHECK constraints, so you get a readable message instead of a constraint code:
Package name is required— missing, non-string, or whitespace-only.Package length must be greater than 0,Package width must be greater than 0.Package height must be 0 (envelope) or greater.Package weight must be 0 or greater.A package with this name already exists— the unique name constraint.
Update A Package
Partial update. {id} is the package uuid. Only the fields present in the body are written, and each present field is validated by the same rules as on create.
| Field Name | Field Type | Required |
|---|---|---|
| name | string | No |
| length | number or string | No |
| width | number or string | No |
| height | number or string | No |
| weight | number or string | No |
| is_default | boolean | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/packages/7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34
fetch('https://<your domain>/api/packages/7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34', {
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": {
"package_id": 4,
"uuid": "7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34",
"name": "Large Box",
"length": "45.00",
"width": "35.00",
"height": "30.00",
"weight": "0.4500",
"is_default": true,
"created_at": "2025-11-04T09:12:44.000Z",
"updated_at": "2025-11-06T11:03:21.000Z"
}
}
Unlike POST /api/packages, the update route declares no payloadSchema.json, so there is no request-schema rejection layer in front of it. The table above documents the fields the handler actually reads; anything else in the body is ignored. All validation happens in the service and surfaces as an error message.
Setting is_default: true swaps the default over in one transaction. Setting is_default: false on the package that is currently the default is refused with This is the default package. Set another package as default first. — there must always be exactly one default. To move the default, promote the other package instead.
Errors: Package not found: {uuid}, the validation messages listed above, and A package with this name already exists.
Delete A Package
{id} is the package uuid. Takes no request body.
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
https://<your domain>/api/packages/7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34
fetch('https://<your domain>/api/packages/7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34', {
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": "7d2f5a91-6c3b-4e08-9a1d-5f7b8c0e2d34"
}
}
Two guards stand in the way:
- The default package cannot be deleted —
The default package cannot be deleted. Set another package as default first. product.package_idis a foreign key withON DELETE RESTRICT. A package still assigned to products is refused with a message that counts them:This package is used by N product(s). Assign those products to another package first.
Orders are never blocked by this. Order rows carry a copy of the dimensions rather than a reference, so deleting a package cannot alter fulfilment history.
Errors: Package not found: {uuid}, plus the two guards above.
500All three handlers catch every thrown error and respond 500 Internal Server Error with the detail in error.message — including the business rules above. The only 400 you will see is a payload-schema violation on POST /api/packages (missing name, length, width or height). Always read error.message.
How Dimensions Reach The Carrier
Dimensions are snapshotted forward at every step rather than joined at read time, so editing or deleting a package never rewrites history.
| Stage | What happens |
|---|---|
| Product | product.package_id references the package. It is nullable — legacy products keep NULL until next edited — and is forced to NULL when the product is marked no_shipping_required. Variant group members share one package. |
| Cart item | On every cart rebuild the product's package is merged onto the loaded product row and copied into cart_item.package_length, package_width, package_height and package_weight — the same refresh semantics as price and product weight. |
| Cart | The cartPackages processor turns those per-item dimensions into a packing proposal stored on cart.packages as an array of { packageUuid, name, length, width, height, tareWeight, goodsWeight }. The default heuristic is deliberately simple: one parcel, sized by the largest item package by volume, carrying that package's tare. Override the whole strategy with addProcessor('cartPackages', ...). |
| Order item | At placement the four package_* columns copy straight across to order_item and are never touched again. There is no foreign key from order rows back to package. |
| Carrier call | When a shipment is created, each item's snapshot becomes CarrierItem.dimensions, and a per-shipment parcel is built over just that shipment's items — a multi-shipment order ships subsets, so the cart-level proposal does not apply. The parcel's weight is goods + tare. |
Per-item weights stay goods-only everywhere. The empty package's weight enters the total in exactly one place — the parcel — which is why the cart's total_weight field resolver adds Σ parcel.tareWeight rather than summing item weights that already include it.
Items with no dimensions — legacy products, or anything with no_shipping_required — contribute no parcel candidate. If no item in a shipment carries dimensions, no parcel is sent and the carrier falls back to its own defaults.
Related Documentation
- Package Management — the design behind parcel sizing and the
cartPackagesprocessor. - Product API — assigning
package_idto a product. - Shipment API — where the parcel is handed to the carrier.
- Shipping Provider API — weight-based shipping rates, which read the same weights.