Packing API guide
Send an order and stock; get each item's box-by-box plan.
1. Get started
Sign up; create a key in your dashboard.
The base URL is https://stoapack.stoalogistics.com/api/v1/.
Keep STOAPACK_API_KEY on your server.
Only successful packing calls use plan balance.
This request packs two cubes into one box. It needs no AI call.
curl -X POST "https://stoapack.stoalogistics.com/api/v1/optimize" \
-H "Content-Type: application/json" \
-H "X-API-Key: $STOAPACK_API_KEY" \
-d '{
"items": [
{
"id": "cube",
"width": 10,
"height": 10,
"depth": 10,
"weight": 100,
"quantity": 2
}
],
"warehouses": [
{
"id": "east",
"inventory": {
"cube": 2
},
"available_boxes": [
{
"id": "row",
"width": 20,
"height": 10,
"depth": 10,
"quantity": 1,
"max_weight": 1000
}
]
}
],
"unit": "cm",
"weight_unit": "g",
"ai_review": false
}'Excerpt: one placement. Full response follows.
{
"success": true,
"solution": {
"warehouses": [
{
"id": "east",
"packing_solution": {
"boxes": [
{
"box_id": "row",
"dimensions": {
"width": 20.0,
"height": 10.0,
"depth": 10.0
},
"packed_items": [
{
"id": "cube",
"quantity": 1,
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": 0,
"dimensions": {
"width": 10,
"height": 10,
"depth": 10
},
"placed_dimensions": {
"width": 10.0,
"height": 10.0,
"depth": 10.0
},
"unit_id": "east:cube:physical-0"
}
]
}
],
"unboxed_items": []
}
}
],
"unavailable_items": [],
"unfitted_outcomes": []
}
}
box_id selects your stocked box. unit_id identifies one physical item;
position and placed_dimensions describe its placement.
Inspect unavailable_items and unfitted_outcomes even when success is true.
Checked response viewer
Paste response JSON to preview its first box. No API call or balance use.
Complete checked response from the isolated engine run.
The cubes sit at (0, 0, 0) and (10, 0, 0). They fill the box: utilization is 1.0. More checked orders.
Python and Node.js clients
Download the clients; save the cURL order as order.json and set these variables:
export STOAPACK_ORDER_FILE=order.json
export STOAPACK_BASE_URL=https://stoapack.stoalogistics.com
export STOAPACK_API_KEY=your-key
python packing.py
node packing.mjs
2. What to send
weight_unit explicitly. Without it, weight:1 means
1 gram, not 1 kilogram; use "weight_unit":"kg" for kilograms.
The unit also applies to box max_weight and nesting mass limits.
Synchronous requests accept at most 200 box instances: sum
available_boxes[].quantity across every warehouse, not just distinct
box types or boxes ultimately used. Supply relevant stock rather than your full
carton catalogue. See limits and async jobs below.Use POST /api/v1/optimize or /api/v1.1/optimize.
Each warehouse owns its box stock. Use unique item and warehouse IDs, and unique box IDs within each warehouse.
Weights use weight_unit; lengths use unit. Defaults are grams and centimetres.
Missing inventory means zero stock; item quantity defaults to one.
Each box needs a positive max_weight.
See the field reference.
| Item field | Values / default | Meaning |
|---|---|---|
orientation | "any" (default) or "upright" |
"upright" keeps the item's height vertical; it may still turn on its base. |
Choosing a goal
minimize_boxes_then_volume— recommended- The explicit name for the same package-count-first ranking as
minimize_boxes; both prefer one Small over one Medium when feasible. Use this name for new integrations."optimization_goal": "minimize_boxes_then_volume" // Six retail items: one stocked Small box when they fit. minimize_boxes— default- Compatibility default; these goals are aliases. Re-selection tries smaller stocked boxes.
"optimization_goal": "minimize_boxes" // Keep one package and reduce its box size when feasible. minimize_volume- Prefer lower total box volume. Package count can differ; compare both before shipping.
"optimization_goal": "minimize_volume" // Try smaller boxes for a low-volume order. minimize_cost- Rank supplied box costs, not carrier rates. Use when carton purchase cost matters.
"optimization_goal": "minimize_cost" // Box field example: {"id": "small", "cost": 0.50}
StoaPack finds a good packing plan quickly. It doesn't guarantee the best possible one.
For custom_instructions, supported rules are
item separation, box assignment, volume fill limit.
Use exact IDs: “Pack the glass in box type B40.” Same-box grouping and placement rules are unsupported.
If we can't apply your instructions, the request fails. We never quietly ignore them.
Free text is an input format, not a promise to enforce arbitrary prose.
Unsupported instructions return 400; an upstream AI-stage failure
returns 502. ai_review:true is a separate, optional review:
it does not add support for otherwise unenforceable rules. Provider choice is
honoured; a failed OpenAI call is not silently retried through Gemini.
Set enable_nesting:true to put items inside declared containers, one level deep.
Supply is_container, max_nested_weight, and internal_dimensions
with length, width, height. If a bundle is too heavy or too large,
we retry its units without nesting. See nesting_fallbacks in that warehouse's packing result.
Hazmat containers are skipped. Nesting with custom instructions returns 400.
When nesting is enabled, nesting_summary reports
containers_used, total_items_nested,
containers_with_hazmat_skipped and the coordinate convention.
The skipped count counts declared hazmat container item rows, not their quantities
or warehouse copies; it is present as zero when none are skipped.
3. Read the result
A unit is one physical item. Each unit ends up in one place:
- Packed: in
solution.warehouses[].packing_solution.boxes[].packed_items. - Nested: inside a packed item's
nested_contents. - Unboxed: shipped as-is when you send
requires_box:false; listed insolution.warehouses[].packing_solution.unboxed_items. - Unfitted: in stock, but left out; see
solution.unfitted_outcomes. - Unavailable: out of stock; see
solution.unavailable_items.
Lists overlap; count units once. Inspect all outcomes, including on 200.
Packed units have IDs, positions and placed sizes. Positions mark the corner nearest the box origin.
Unfitted reason codes distinguish size, mass, exhausted box stock and fill limits.
Use reason for machine decisions. The legacy outcome_reason
is readable text for most outcomes but holds the literal
hazmat_unit_quantity_limit code for an over-limit sealed hazmat unit.
We retain these mixed historical values for compatibility. Optional
details supplies a human-readable explanation of that quantity limit;
it is not guaranteed on other outcomes and must not be parsed as a code.
Outcome masses echo the request's weight_unit, which is also attached to each mass-bearing row.
Check metadata.optimization_goal for the goal actually applied.
Nested positions are inside the container before rotation; apply the container's rotation to draw them in the box.
Above the box floor, at least 75% of a unit's base must contact coplanar unit tops.
This support rule can change box choice or leave units unfitted; it does not establish stability or load-bearing capacity.
Each box's pack_sequence gives physical-unit packing order.
Plans made before 18 October 2026 may include placements with less than 75% support.
The unsupported_placement warning flags base contact below 50%; it is not a stability assessment.
4. Errors and retries
Unknown fields return 400 unknown_field with the field path.
Out-of-range dimensions or masses return 400 numeric_range before packing.
| Status | Code | Retryable? | What to do |
|---|---|---|---|
| 400 | validation_error | No | Fix fields or unsupported instructions. |
| 401 / 403 | request_error, jobs_not_approved | No | Check key, balance and access. |
| 413 / 422 | body_too_large, unsupported_coverage | No | Reduce size or correct unsupported coverage. |
| 422 | work_budget_exceeded | No | Split the order; don't repeat unchanged. |
| 422 | review_requirements_not_met | No | Inspect and change the request. |
| 429 | rate_limited | Yes | Wait for Retry-After. |
| 502 | provider_error | Yes | Retry later with backoff. |
| 503 | capacity_full, others | Read retryable | Retry only when true; honor Retry-After. |
For jobs_worker_unavailable, no observed worker means non-retryable.
A missed recent heartbeat can be retryable. Follow retryable.
{"success":false,"status":503,"error":"capacity_unavailable","code":"capacity_full","retryable":true}
Check status and Content-Type before parsing: app errors are JSON, but proxy errors may be HTML.
Keep X-Request-ID for support. See timing and rate metadata.
5. Limits
One optimize endpoint serves parcels and pallets. Pallet jobs and pallet nesting are not supported.
Pallets
Use box.kind:"pallet" and pallet_options for full or mixed layers.
Keep height vertical with orientation:"upright".
See the contract 1.4.0 pallet reference and
checked request.
| Per synchronous request | Parcel maximum | Pallet maximum | If exceeded |
|---|---|---|---|
| Body bytes | 262,144 (256 KiB) | 1,048,576 | 413 |
| Item types | 40 | 40 | 422 |
| Item units | 80 | 1,000 | 422 |
| Warehouses | 5 | 5 | 422 |
| Box types | 25 | 25 | 422 |
| Box stock | 200 | 200 | 422 |
| (units)² × (number of box types, minimum 1) | 100,000 | 100,000 | 422 |
| Packing time | 2.0 seconds | 422 | |
| Each dimension, in cm or in as requested | 0.01–10000 | 400 outside range | |
| Box volume, in cubic request units | At least 0.001 | 400 below minimum | |
| Mass after conversion | Zero, or 0.001 g–1,000,000,000 kg | 400 outside range | |
6. Hazardous materials
Known terminal unfitted reasons:
too_large_for_any_box, too_heavy_for_any_box,
no_box_stock, exceeds_box_fill_limit,
no_upright_fit, exceeds_load_height,
packing_constraints_or_capacity, and
hazmat_unit_quantity_limit (one sealed unit exceeds a supported
hazmat quantity limit). Read solution.unfitted_outcomes[].reason;
retain unknown future codes. Legacy unfitted_reason text is retained,
not a substitute for the machine code. Inventory-unavailable outcomes are separate.
Missing net quantity: ground requests are not rejected merely
because hazmat_net_quantity is missing. Where quantity coverage exists,
the engine estimates from declared mass/density with confidence-based safety
margins; this is not measured net content. Where coverage is unsupported, quantity
checks remain unverified and quantity_limits_checked is false.
Warnings beginning MISSING_NET_QUANTITY name each affected item ID.
Air liquid quantity checks require explicit per-unit net volume in mL or L;
missing volume returns 422 unsupported_coverage, not a ground fallback.
Quantity-driven distribution: packages with
hazmat_quantity_split report the item ID, requested quantity,
maximum units per package, actual packed quantity and package count.
Their placements carry package_split_reason:"hazmat_package_quantity_limit"
and existing unit_id values. This means the quantity cap was enforced
across multiple packages; geometry may also constrain packing. It never means
the contents of one sealed unit were divided.
Package quantity metadata: hazmat_details.quantity_info
contains confidence, safety_margin_applied, and
gross_hazmat_weight_kg. Air packages also contain
q_value and q_value_limit. These are package values,
not the original per-unit net declaration. See the typed
OpenAPI schemas.
Send hazmat_un and actual per-unit net quantity with its unit.
Different dangerous-good item types use separate boxes, even with the same class or UN number.
Eligible units split at supported package limits; an over-limit sealed unit is an unfitted outcome, not split into smaller units.
See current UN and transport coverage.
Use classification_un_numbers for known classification,
ground_quantity_check_un_numbers and air_quantity_check_un_numbers
for enforced transport quantity coverage, and distinguish quantity-reference-only
data from supported transport. One unsupported air UN fails the whole request:
preflight each declared UN; the API does not silently omit that item.
Unsupported air or sea coverage returns 422; unknown ground UN numbers stay isolated with warnings. Lithium by air is unsupported.
Declare state of charge as 0–100 percent; this value is not verified as compliance.
Hazmat checks are decision support. See what's covered →
7. Glossary
Before you integrate
Built by Stoa Logistics. Call from your server.