Reference
REST API
Everything the web app does, a bearer token can do.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Liveness. No auth, no database. |
| POST | /pack | Run the solver on a list of lines. |
| POST | /orders/pack | Run the solver on a NormalizedOrder. |
| GET | /cartons | List cartons. |
| POST | /cartons | Add a carton. |
| POST | /cartons/bulk | Load or refresh cartons. |
| GET | /cartons/:id | One carton. |
| PATCH | /cartons/:id | Amend a carton. |
| DELETE | /cartons/:id | Remove a carton. |
| GET | /items | List items. |
| POST | /items | Add an item. |
| POST | /items/bulk | Load or refresh items. |
| GET | /items/:id | One item. |
| PATCH | /items/:id | Amend an item. |
| DELETE | /items/:id | Remove an item. |
| GET | /jobs | Pack history, newest first. |
| GET | /jobs/:id | One job with its full plan. |
| GET | /usage | Counts against entitlements. |
| GET | /openapi.json | The machine-readable contract. |
Base path /api/v1 on your own instance origin.
Authentication
Authorization: Bearer cpp_live_7Qk2nR4vT8xW1yZ3aB5cD6eF9gH0jK2mN4pQ6rS8
| Rule | Detail |
|---|---|
| Where keys come from | /api-keys in the app. Shown once; only the SHA-256 is stored, with the first 12 characters kept as a visible prefix. |
| Scopes | read covers every GET. write is additionally required for POST, PATCH, DELETE and both pack routes. |
| Tenancy | A key is bound to one organization. No route in the product spans organizations. |
| Revocation | Immediate. Jobs the key created stay attributed to it. |
Response envelope
{
"data": { "…": "the payload" },
"meta": { "page": 1, "pageSize": 50, "total": 120 }
}{
"error": {
"code": "carton_not_found",
"message": "No carton matches that identifier.",
"details": []
}
}No route returns a bare array or a bare object, so a client unwraps once. meta appears only on collections; details is always an array. GET /openapi.json is the one exception — an OpenAPI document wrapped in data is not an OpenAPI document.
Errors
| Status | code | Cause |
|---|---|---|
| 400 | invalid_json | The body is not JSON, or is not an object. |
| 400 | validation_failed | One field per entry in details. |
| 400 | invalid_query | A query parameter is out of range or misspelled. |
| 401 | unauthorized | Missing, unknown or revoked token. |
| 403 | forbidden | Valid key, missing write scope. |
| 404 | <resource>_not_found | No such record in your organization. Another tenant’s id is a 404, never a 403. |
| 402 | plan_limit_reached | A plan ceiling. details names the limit and plan. |
| 409 | <resource>_sku_conflict | That SKU is taken. PATCH it instead. |
| 429 | rate_limited | Per-key bucket empty. Honor Retry-After. |
| 500 | internal_error | Unexpected. Nothing partial is persisted. |
400 Bad Request
{
"error": {
"code": "validation_failed",
"message": "lines.0.quantity: Quantity must be 1 or more.",
"details": [
{
"field": "lines.0.quantity",
"code": "too_small",
"message": "Quantity must be 1 or more."
}
]
}
}Rate limits
| Ceiling | Behavior |
|---|---|
| Requests per minute | Token bucket per key: 30 on Free, 300 on Pro. Over it is 429 with Retry-After in whole seconds. Retry and nothing is lost. |
| Pack jobs per month | 50 on Free, 25,000 on Pro, counted per UTC month across app, API, MCP and ERP. Exhausted is 402. Retrying will not help; reads keep working. |
429 Too Many Requests 402 Payment Required
Retry-After: 12
{ "error": { { "error": {
"code": "rate_limited", "code": "plan_limit_reached",
"message": "Rate limit "message": "…",
exceeded. Retry in "details": [
12 seconds.", { "limit": "packJobs",
"details": [] "plan": "FREE" }
} } ] } }No header reports the remaining monthly allowance. Poll GET /usage instead.
Pagination
| Name | Type | Req | Description |
|---|---|---|---|
| page | integer ≥ 1 | no | One-based. Default 1. |
| pageSize | integer ≥ 1 | no | Default 50, clamped to 200. meta reports what you got. |
| q | string | no | Substring match; no wildcards. |
Ordering is stable — SKU ascending for catalogs, newest first for jobs — so paging a collection that is being written to does not repeat or skip rows.
Pack
/api/v1/packscope: writeSolve an order and store the plan. Consumes one pack job.
| Name | Type | Req | Description |
|---|---|---|---|
| lines | PackLine[] | yes | 1 to 500 lines. |
| lines[].sku | string | yes | Catalog SKU, or any key when dimensions are inline. |
| lines[].quantity | integer 1–10000 | yes | Units of this SKU. |
| lines[].dimensions | { length, width, height } | no | Overrides the catalog for this line. |
| lines[].weight | number | no | Per unit. Send it whenever dimensions is sent. |
| lines[].name | string | no | Label; falls back to the catalog name. |
| lines[].fragile | boolean | no | Overrides the catalog flag for this line. |
| lines[].orientationLock | enum | no | Overrides the catalog lock for this line. |
| lines[].groupKey | string | no | Keeps this line with others sharing the key. |
| objective | MIN_CARTONS | MIN_COST | MIN_DIM_WEIGHT | BEST_FIT | no | Defaults to your organization setting. What each one does. |
| reference | string ≤ 120 | no | Your order number. externalId is a synonym and wins if both are sent. |
| cartonIds | string[] ≤ 200 | no | Restrict the search to these cartons. |
| options | PackOptions | no | Seven solver controls, clamped to your plan. Table. |
| save | boolean | no | Default true. False returns the plan and writes nothing. |
curl -X POST "$CPP_URL/api/v1/pack" \
-H "Authorization: Bearer $CPP_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference": "SO-10422",
"objective": "MIN_CARTONS",
"options": { "allowMultipleCartons": true, "supportThreshold": 0.7 },
"lines": [
{ "sku": "KTC-SKLT-12", "quantity": 2 },
{ "sku": "GLS-TMBL-4PK", "quantity": 1, "fragile": true },
{
"sku": "CUSTOM-PLAQUE",
"quantity": 1,
"name": "Engraved slate plaque",
"dimensions": { "length": 11, "width": 8, "height": 0.75 },
"weight": 3.4,
"orientationLock": "FLAT"
}
]
}'200 OK
{
"data": {
"jobId": "clw3k1n2t0004v8hq7x1s9abc",
"reference": "PJ-4F2A9C",
"externalId": "SO-10422",
"unknownSkus": [],
"result": {
"success": true,
"reference": "SO-10422",
"objective": "MIN_CARTONS",
"unitSystem": "IMPERIAL",
"cartons": [
{
"index": 1,
"cartonId": "clw3jz8pa0001v8hq2m4d7xyz",
"cartonSku": "RSC-12X10X6",
"cartonName": "RSC 12 × 10 × 6",
"cartonType": "BOX",
"usable": { "length": 11.5, "width": 9.5, "height": 5.5 },
"outer": { "length": 12.25, "width": 10.25, "height": 6.25 },
"placements": [
{
"itemId": "clw3jzabc0002v8hq9k3f1def",
"sku": "KTC-SKLT-12",
"name": "12 in stainless skillet",
"sequence": 1,
"position": { "x": 0, "y": 0, "z": 0 },
"dimensions": { "length": 11, "width": 8, "height": 0.75 },
"rotation": 0,
"orientation": "flat",
"weight": 3.4,
"layer": 0,
"fragile": false,
"color": "#6fa8dc"
}
/* … 3 more placements */
],
"contentsWeight": 9.8,
"tareWeight": 0.6,
"grossWeight": 10.4,
"dimWeight": 5.6458,
"billableWeight": 10.4,
"utilization": 0.7103,
"usedVolume": 426.8,
"usableVolume": 600.875,
"voidVolume": 174.075,
"cartonCost": 0.94,
"instructions": [
"1. Lay KTC-SKLT-12 12 in stainless skillet flat in the back-left corner on the floor of the carton (11.0 x 8.0 x 0.75 in)."
/* … one line per step */
],
"warnings": []
}
],
"unpacked": [],
"summary": {
"totalCartons": 1,
"totalItems": 4,
"packedItems": 4,
"totalContentsWeight": 9.8,
"totalGrossWeight": 10.4,
"totalBillableWeight": 10.4,
"totalCartonCost": 0.94,
"averageUtilization": 0.7103,
"totalVoidVolume": 174.075,
"strategy": "volume-desc",
"candidatesEvaluated": 84,
"computeMs": 37
},
"warnings": [],
"errors": []
}
}
}| Outcome | What you get |
|---|---|
| Unknown SKU | Listed in unknownSkus and left out of the plan. Still a 200 — a missing master-data row is not a malformed request. |
| Did not fit | In result.unpacked with a reason, and result.success is false. Still a 200 — the run happened. |
| save: false | jobId and reference are null, nothing is written, the allowance is untouched. |
/api/v1/orders/packscope: writeThe same solver, taking a whole NormalizedOrder and returning an absolute deepLink.
Body shape, signing and the deep-link pattern: ERP integration.
Cartons
/api/v1/cartonsscope: readPaginated, SKU ascending. Filters: page, pageSize, q, active.
/api/v1/cartonsscope: writeCounts against the carton-type ceiling. A duplicate SKU is 409.
| Field group | Rule |
|---|---|
| Required | sku name type innerLength innerWidth innerHeight maxWeight tareWeight cost padding quantityOnHand reorderPoint maxBulge fillFactor |
| Booleans | active and flexible are read as false when absent — the same schema backs the app’s HTML forms. Send "active": true explicitly. |
| Cross-field | Twice padding must leave space on every axis, and no outer dimension may be under its inner counterpart. |
| Types and ranges | Per field in the column reference and in the OpenAPI document. |
curl -X POST "$CPP_URL/api/v1/cartons" \
-H "Authorization: Bearer $CPP_KEY" \
-H "Content-Type: application/json" \
-d '{
"sku": "MLR-10X13",
"name": "Padded mailer 10 × 13",
"type": "MAILER",
"innerLength": 10, "innerWidth": 13, "innerHeight": 1,
"maxWeight": 8, "tareWeight": 0.1, "cost": 0.31, "padding": 0,
"quantityOnHand": 2600, "reorderPoint": 500,
"active": true,
"flexible": true, "maxBulge": 1.5, "fillFactor": 0.85
}'201 Created
{
"data": {
"id": "clw3jz8pa0001v8hq2m4d7xyz",
"sku": "MLR-10X13",
"name": "Padded mailer 10 × 13",
"type": "MAILER",
"innerLength": 10, "innerWidth": 13, "innerHeight": 1,
"outerLength": null, "outerWidth": null, "outerHeight": null,
"maxWeight": 8, "tareWeight": 0.1, "cost": 0.31, "padding": 0,
"quantityOnHand": 2600, "reorderPoint": 500,
"active": true,
"flexible": true, "maxBulge": 1.5, "fillFactor": 0.85,
"notes": null,
"createdAt": "2026-09-28T09:14:02.511Z",
"updatedAt": "2026-09-28T09:14:02.511Z"
}
}/api/v1/cartons/:idscope: read404 when the id is not yours.
/api/v1/cartons/:idscope: writeSend only what changes; the merged record is held to the full create rules.
/api/v1/cartons/:idscope: write204. Prefer active: false when the size may come back.
Items
/api/v1/itemsscope: readFilters: page, pageSize, q, active, category.
/api/v1/itemsscope: writeCounts against the item ceiling. A duplicate SKU is 409.
| Field group | Rule |
|---|---|
| Required | sku name length width height weight maxStackWeight orientationLock |
| Booleans | fragile, stackable and active are read as false when absent. That matters most for stackable: omit it and nothing may be placed on the item. |
| Types and ranges | The column reference |
/api/v1/items/:idscope: read/api/v1/items/:idscope: write/api/v1/items/:idscope: writeHistoric jobs keep their own snapshot of the SKU and its dimensions.
Bulk load
/api/v1/cartons/bulk · /api/v1/items/bulkscope: writeUp to 1,000 rows, validated individually, written in one transaction.
| Name | Type | Req | Description |
|---|---|---|---|
| mode | "CREATE_ONLY" | "UPSERT" | yes | What happens to a SKU you already have. |
| rows | CartonInput[] | ItemInput[] | yes | Whole records — the same shape the POST routes take. |
curl -X POST "$CPP_URL/api/v1/items/bulk" \
-H "Authorization: Bearer $CPP_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "UPSERT",
"rows": [
{
"sku": "GLS-TMBL-4PK", "name": "Tumbler, 4 pack",
"length": 8, "width": 8, "height": 6, "weight": 3.1,
"fragile": true, "stackable": false, "maxStackWeight": 0,
"orientationLock": "THIS_SIDE_UP", "active": true
},
{
"sku": "ACC-CBL-6", "name": "6 ft cable",
"length": 4.5, "width": 4.5, "height": 1.1, "weight": 0.21,
"stackable": true, "maxStackWeight": 0,
"orientationLock": "FREE", "active": true
}
]
}'200 OK
{
"data": {
"mode": "UPSERT",
"counts": { "total": 2, "created": 1, "updated": 1, "skipped": 0, "failed": 0 },
"results": [
{ "index": 0, "sku": "GLS-TMBL-4PK", "status": "updated" },
{ "index": 1, "sku": "ACC-CBL-6", "status": "created" }
]
}
}| Behavior | Detail |
|---|---|
| Per row | created, updated, skipped or failed, with the submitted index. |
| Repeated SKU | Skipped rather than applied twice; the report names the row that won. |
| Plan ceiling | Checked against the total the load would leave behind, so re-upserting costs nothing. |
| Bigger files | Use the importer. Column reference. |
Pack jobs
/api/v1/jobsscope: readSummaries only, newest first. A page of full plans would be megabytes.
| Name | Type | Req | Description |
|---|---|---|---|
| externalId | string | no | Exact match on your own order id. |
| q | string | no | Matches our reference and your external id. |
| status | SUCCESS | PARTIAL | FAILED | no | Filter by outcome. |
| source | WEB | API | MCP | INTEGRATION | no | Which door the run came through. |
| page · pageSize | integer | no | As every collection. |
curl "$CPP_URL/api/v1/jobs?externalId=SO-10422" \ -H "Authorization: Bearer $CPP_KEY"
200 OK
{
"data": [
{
"id": "clw3k1n2t0004v8hq7x1s9abc",
"reference": "PJ-4F2A9C",
"externalId": "SO-10422",
"source": "API",
"status": "SUCCESS",
"objective": "MIN_CARTONS",
"unitSystem": "IMPERIAL",
"cartonCount": 1,
"itemCount": 4,
"packedCount": 4,
"totalBillableWeight": 10.4,
"totalCost": 0.94,
"averageUtilization": 0.7103,
"computeMs": 37,
"createdAt": "2026-09-28T09:14:02.511Z"
}
],
"meta": { "page": 1, "pageSize": 50, "total": 1 }
}/api/v1/jobs/:idscope: readAdds deepLink, the requested lines, and the full stored result.
| Field | Meaning |
|---|---|
deepLink | Absolute URL of the visual plan on your instance. |
lines | The requested lines as they were solved — snapshotted, so catalog edits do not rewrite history. |
result | The stored PackResult, byte-identical to the run. |
| Retention | 30 days on Free; kept indefinitely on Pro. |
Usage
/api/v1/usagescope: readCounts and limits as parallel objects, so a client can meter each row. null means unlimited.
200 OK
{
"data": {
"period": "2026-09",
"plan": { "id": "PRO", "name": "Pro" },
"usage": {
"cartonTypes": 24, "items": 412, "seats": 6,
"apiKeys": 3, "packJobsThisMonth": 1840
},
"limits": {
"cartonTypes": 1000, "items": null, "seats": 25,
"apiKeys": 10, "packJobsPerMonth": 25000
},
"solver": { "maxCartonsPerShipment": 50, "maxStrategies": 10 },
"api": { "rateLimitPerMinute": 300 },
"features": { "webhooks": true, "erpIntegrations": true, "historyRetentionDays": null }
}
}Webhooks
| Element | Value |
|---|---|
| Events | pack.completed · pack.failed · carton.low_stock |
| Envelope | { id, event, createdAt, data } |
| Signature | X-CartonPackPro-Signature: t=<unix>,v1=<hex> over t + "." + body. Scheme and a verifier. |
| Payloads | Per event, with delivery rules: ERP integration. |
| Managed at | /integrations. A Pro entitlement. |
OpenAPI
/api/v1/openapi.jsonno authOpenAPI 3.1, generated per request so the servers URL is this deployment's origin.
Every schema, every enum and every ceiling on this page, machine-readable — generate a client instead of writing one. Open the document.
Health
/api/v1/healthno authFor load balancers. Touches no data, so it stays 200 through a database outage.
{ "data": { "status": "ok", "version": "1.0.0", "time": "2026-09-28T09:14:02.511Z" } }