Reference
REST API
Pack orders, manage cartons and items, and read pack history with an API key.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Checks the server is up. No auth, no database. |
| POST | /pack | Run the cartonizer on a list of lines. |
| POST | /orders/pack | Run the cartonizer on a NormalizedOrder. |
| GET | /cartons | List cartons. |
| POST | /cartons | Add a carton. |
| POST | /cartons/bulk | Create or update up to 1,000 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 | Create or update up to 1,000 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 | Usage counts and plan limits. |
| GET | /settings | Packing defaults and whether live rates are connected. |
| GET | /openapi.json | The OpenAPI 3.1 document. |
Base path /api/v1 on the address you sign in at.
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 is required for every GET except /settings, which any key reads. write is required for every POST, PATCH and DELETE, including both pack routes. |
| Organization | A key reaches only its own organization’s data. |
| 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": []
}
}Every response puts the payload in data, or the failure in error. meta appears only on collections; details is always an array. GET /openapi.json is the one exception: it returns the bare document so OpenAPI tools can read it.
Errors
| Status | code | Cause |
|---|---|---|
| 400 | invalid_json | The body is not JSON. |
| 400 | invalid_body | The body is JSON but not an object. |
| 400 | validation_failed | One or more fields are invalid; details has one entry per field. |
| 400 | invalid_query | A query parameter is out of range or misspelled. |
| 400 | empty_patch | A PATCH body names no field to change. |
| 401 | unauthorized | Missing, unknown or revoked token. |
| 403 | forbidden | Valid key, missing the scope the route requires. |
| 404 | <resource>_not_found | No such record in your organization. Another organization’s id also returns 404, not 403. |
| 402 | plan_limit_reached | A plan limit is reached. details names the limit and plan. |
| 409 | <resource>_sku_conflict | That SKU is taken. PATCH it instead. |
| 429 | rate_limited | Too many requests from this key. Wait Retry-After seconds, then retry. |
| 500 | internal_error | Unexpected. |
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
| Limit | Behavior |
|---|---|
| Requests per minute | Token bucket per key: 30 on Free, 300 on Pro, 1,200 on Business and 1,200 on Enterprise. Over it, you get 429 with Retry-After in whole seconds. The request did nothing, so retry it. |
| Pack jobs per month | 50 on Free, 2,500 on Pro and 15,000 on Business included, counted per UTC month across app, API, MCP and ERP. Past them, $0.08 a job up to 7,500 on Pro and $0.05 a job up to 45,000 on Business, billed on the next invoice. Free stops at its included jobs. At the stop, packing returns 402 and 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" }
} } ] } }For pack jobs left this month, call GET /usage.
Pagination
| Name and Type | Req | Description |
|---|---|---|
| page: integer ≥ 1 | no | One-based. Default 1. |
| pageSize: integer ≥ 1 | no | Default 50; anything over 200 becomes 200. meta shows the size used. |
| q: string | no | Substring match; no wildcards. |
Pages are offset-based: SKU ascending for catalogs, newest first for jobs. If rows are added or removed while you page, later pages shift, so a row can repeat or be skipped.
Pack
/api/v1/packscope: writeCartonize an order and store the plan. Consumes one pack job.
| Name and Type | Req | Description |
|---|---|---|
| lines: PackLine[] | yes | 1 to 500 lines. |
| lines[].sku: string | yes | Catalog SKU, or a new one when dimensions are inline. 1–64 characters: letters, digits, . _ - /, starting with a letter or digit. |
| 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[].dimensionUnit: in | ft | cm | m | mm | no | The unit of dimensions and nestIncrement, when it is not your organization’s. Converted before packing. |
| lines[].weightUnit: lb | oz | kg | g | no | The unit of weight and maxStackWeight, when it is not your organization’s. Converted before packing. |
| 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[].nestIncrement: number 0–500 | no | Height each further unit adds to a nest, like stacked cups. Less than the height. 0 means the line does not nest. Overrides the catalog for this line. |
| lines[].maxNestQuantity: integer 2–10000 | no | Most units in one nest. Overrides the catalog for this line. |
| lines[].groupKey: string | no | Lines with the same key go in one carton when possible; a split adds a warning. |
| lines[].lineRef: string ≤ 64 | no | Your own id for the line, e.g. a sales order line id. Trimmed; blank is dropped. Stored on the job line and returned by GET /jobs/:id, and in lines on POST /orders/pack. |
| objective: MIN_CARTONS | MIN_COST | MIN_DIM_WEIGHT | BEST_FIT | LOWEST_RATE | no | Defaults to your organization setting. What each one does. LOWEST_RATE needs shipTo, save true and a ShipStation API key on your organization. If any of the three is missing, the response keeps the MIN_COST plan and result.warnings names what is missing. |
| reference: string ≤ 120 | no | Your order number. externalId is a synonym and wins if both are sent. |
| cartonIds: string[] ≤ 200 | no | Pack only into these cartons. |
| options: PackOptions | no | Seven packing settings, capped by your plan. See the options table. |
| save: boolean | no | Default true. False returns the plan and writes nothing. |
| shipTo: { postalCode, country, name? } | no | Where the order goes, for live rates. country is a two-letter country code, e.g. US. Not stored. |
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": []
},
"rates": null
}
}| Outcome | What you get |
|---|---|
| Unknown SKU | Listed in unknownSkus and left out of the plan. Still a 200. |
| Did not fit | In result.unpacked with a reason, and result.success is false. Still a 200. |
| Nested units | One placement per nest. quantity is the units in it, and nest holds the increment and the carton axis the nest runs along. Its dimensions and weight are the whole nest’s. A placement without quantity is one unit. |
| save: false | jobId and reference are null, nothing is saved, and no pack job is used. |
| No live rates | rates is null: save is false, your organization has no ShipStation API key, the request has no shipTo, or no line packed. |
| ShipStation API failure | rates.rates is empty and rates.error says why. Still a 200. |
Live rates
When the request has a shipTo, Carton Pack Pro gets live rates for the plan through your organization’s ShipStation API key, set under Configuration → Integrations → ShipStation API. POST /pack gets rates only for saved runs; POST /orders/pack also gets them for save: false runs. The response, GET /jobs/:id and the webhooks return the rates in rates.
| Rating limit | Value |
|---|---|
| Carrier-list requests | 1 per job |
| Estimate requests | At most 1 per distinct carton and carrier |
| Requests at a time | 4 per carrier |
| Timeout per request, including reading the response body | 8 seconds |
| Deadline for all rating in the job | 15 seconds |
| Errors that stop the job's remaining requests | 401, 403, 429 |
| Errors that drop only that carrier from the plans using that carton | Any other error, such as a 400, 5xx, timeout or network failure; the deadline |
"rates": {
"provider": "SHIPSTATION",
"fetchedAt": "2026-10-06T15:00:00.000Z",
"kind": "ESTIMATE",
"rates": [
{
"carrierName": "USPS", "carrierCode": "stamps_com",
"carrier": "USPS",
"serviceCode": "usps_ground_advantage", "serviceName": "USPS Ground Advantage",
"amount": 12.41, "currency": "usd", "deliveryDays": 5, "negotiated": false
}
/* … one per service that quoted every carton, cheapest first */
],
"error": null,
"warnings": ["No weight on ACC-CBL-6: rates count them as 0 lb."],
"comparison": [
{
"objectives": ["MIN_COST"],
"cartons": [{ "sku": "RSC-14X12X8", "count": 1 }],
"cartonCount": 1, "packedItems": 4, "cartonCost": 0.78,
"rate": { "serviceName": "USPS Ground Advantage", "amount": 13.86 /* … */ },
"total": 14.64, "chosen": false, "error": null
},
{
"objectives": ["MIN_DIM_WEIGHT", "MIN_CARTONS", "BEST_FIT"],
"cartons": [{ "sku": "RSC-12X10X6", "count": 1 }],
"cartonCount": 1, "packedItems": 4, "cartonCost": 0.94,
"rate": { "serviceName": "USPS Ground Advantage", "amount": 12.41 /* … */ },
"total": 13.35, "chosen": true, "error": null
}
]
}| Field | Holds |
|---|---|
kind | ESTIMATE: ShipStation API estimates, which may leave out fuel surcharges and other fees. |
rates | One entry per service that quoted every carton, summed over the cartons, cheapest first. |
rates[].carrier | UPS, FEDEX or USPS, or null for another carrier. The first entry whose carrier equals result.carrier is that carrier’s cheapest rate. Absent on jobs stored before 9 October 2026. |
error | Why rates is empty, when it is. |
warnings | One message naming the SKUs with no weight, which the rates count as 0 lb (0 kg on a metric organization). |
comparison | LOWEST_RATE only. One entry per distinct plan, in the order its objectives ran. See the rules. |
comparison[].objectives | The objectives that produced this plan. |
comparison[].rate | The plan’s cheapest rate. When the run uses a carrier’s billing rules (options.carrier, else Carrier under Settings → Organization), the cheapest rate from that carrier. |
comparison[].total | cartonCost plus rate.amount, rounded to the cent. |
comparison[].chosen | True on the plan returned in result. result.objective names the objective that produced it. |
comparison[].error | Why the plan has no total. One of the values in the next table. |
| comparison[].error | When |
|---|---|
Packs N fewer units (Packs 1 fewer unit for one) | The plan packs N fewer units than the plan that packs the most. |
Not rated within 15 seconds | The 15-second rating deadline passed before any service quoted every carton of the plan, or before the carrier billed answered for every carton. |
No <Carrier> rate, e.g. No UPS rate | The plan has no service from the carrier in result.carrier. |
<Carrier> returned no rates: <ShipStation's message>. | A request to the carrier billed failed for one of the plan's cartons. |
Not compared: plans are compared without <Carrier>, e.g. Not compared: plans are compared without FedEx | Every service of the plan is from a carrier that failed or was cut off on another plan. rate holds the plan's cheapest service. |
No rate | The run names no carrier, and no service quoted every carton of the plan. |
Not compared: plans are compared in <CURRENCY>, e.g. Not compared: plans are compared in USD | Plans are compared in the currency of the first rated plan’s cheapest service, and this plan has no service in it. rate holds this plan’s cheapest service, in its own currency. |
| The ShipStation API error text | Rating that plan failed. |
Pack an order
/api/v1/orders/packscope: writePacks a whole NormalizedOrder with the same cartonizer and returns an absolute deepLink.
| Name and Type | Req | Description |
|---|---|---|
| externalId: string ≤ 120 | yes | Your order number, stored on the job. GET /jobs?externalId= finds it. |
| source: string ≤ 60 | yes | The sending system, e.g. netsuite. Not stored: the job records INTEGRATION. |
| lines: PackLine[] | yes | 1 to 500 lines, as on POST /pack. Send lineRef on each to match lines in the response back to your own. |
| requestedObjective: MIN_CARTONS | MIN_COST | MIN_DIM_WEIGHT | BEST_FIT | LOWEST_RATE | no | Defaults to your organization setting. LOWEST_RATE needs shipTo and a ShipStation API key, and compares rates with save true or false. |
| save: boolean | no | Default true. False runs a preview: no job, no pack job counted or checked against the allowance, no webhook. Live rates still come back, and their ShipStation API requests count as rate_calls. |
| options: PackOptions | no | As on POST /pack. On a metric organization, options.carrier fails the plan, with the reason in result.errors. |
| replaces: string ≤ 64 | null | no | The jobId of the earlier plan this run replaces. A saved run marks that job Replaced by PJ-… in the app. Only a job of this organization for the same externalId is marked; any other id is ignored. |
| shipTo: { postalCode, country, name? } | no | For live rates, as on POST /pack. Not stored. |
| metadata: object of scalars | no | Validated, not stored. |
curl -X POST "$CPP_URL/api/v1/orders/pack" \
-H "Authorization: Bearer $CPP_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "SO-2190",
"source": "netsuite",
"save": false,
"requestedObjective": "LOWEST_RATE",
"options": { "carrier": "UPS" },
"shipTo": { "postalCode": "94107", "country": "US" },
"lines": [
{ "sku": "KTC-SKLT-12", "quantity": 2, "lineRef": "1" },
{ "sku": "GLS-TMBL-4PK", "quantity": 1, "lineRef": "3" }
]
}'200 OK
{
"data": {
"jobId": null,
"reference": null,
"externalId": "SO-2190",
"deepLink": null,
"unknownSkus": [],
"result": { "carrier": "UPS" /* … the PackResult */ },
"rates": { /* … RateQuote, with comparison */ },
"lines": [
{ "lineRef": "1", "specId": "clw3jzabc0002v8hq9k3f1def",
"quantity": 2, "packedQuantity": 2 },
{ "lineRef": "3", "specId": "clw3jzabc0003v8hq4n7g2ghi",
"quantity": 1, "packedQuantity": 1 }
]
}
}| Field | Holds |
|---|---|
jobId · reference · deepLink | The stored job, its reference and the absolute URL of its plan. Null when save is false. |
result · rates · unknownSkus | As on POST /pack. result.carrier is the carrier whose rules set billable weight. |
lines | One per request line, in request order. |
lines[].specId | The item id this line was packed under; its placements carry the same id in itemId. Null for a line in unknownSkus. |
lines[].packedQuantity | Units of the line that were packed, out of quantity. |
| Plans | Included with Pro, Business and Enterprise; other plans get a 402. |
Cartons
/api/v1/cartonsscope: readPaginated, SKU ascending. Filters: page, pageSize, q, active.
/api/v1/cartonsscope: writeCounts toward your plan's carton-type limit. A duplicate SKU returns 409.
| Field group | Rule |
|---|---|
| Required | sku name type innerLength innerWidth innerHeight maxWeight tareWeight cost padding maxBulge fillFactor |
| Booleans | When absent, active is read as true and flexible as false. |
| Cross-field | Each inner dimension must be more than twice padding, and each outer dimension at least its inner one. |
| 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,
"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,
"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 updated carton must pass the same rules as a create.
/api/v1/cartons/:idscope: writeReturns 204. For a size you may use again, set active: false instead.
Items
/api/v1/itemsscope: readFilters: page, pageSize, q, active, category.
/api/v1/itemsscope: writeCounts toward your plan's item limit. A duplicate SKU returns 409.
| Field group | Rule |
|---|---|
| Required | sku name length width height weight maxStackWeight orientationLock |
| Booleans | When absent, stackable and active are read as true and fragile as false. |
| Cross-field | nestIncrement must be less than height, and maxNestQuantity needs a nestIncrement above 0. |
| Types and ranges | The column reference |
/api/v1/items/:idscope: read/api/v1/items/:idscope: writeClearing nestIncrement, with null or 0, clears maxNestQuantity too.
/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 and Type | Req | Description |
|---|---|---|
| mode: "CREATE_ONLY" | "UPSERT" | yes | CREATE_ONLY skips SKUs you already have; UPSERT updates them. |
| 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 | The first row with that SKU is applied; later rows with it are skipped and name that row. |
| Plan limit | Checked against your total after the load, so updating SKUs you already have does not count toward it. |
| Over 1,000 rows | Use the importer. Column reference. |
Pack jobs
/api/v1/jobsscope: readSummaries only, newest first. The full plan is on GET /api/v1/jobs/:id.
| Name and 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 | Where the run came from. |
| page · pageSize: integer | no | Same 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 | Full URL of the visual plan in the app. |
lines | The requested lines as packed, copied at pack time so later catalog edits do not change them. Sorted by SKU. |
lines[].lineRef · lines[].specId | The lineRef sent with the line, and the id its placements carry in itemId. lineRef is null when the line was sent without one. specId is null for a line with no dimensions. Both are null on jobs stored before 9 October 2026. Match lines by lineRef, not by position. |
result | The PackResult exactly as the run returned it. |
rates | The live rates fetched when the job was packed, or null. |
| Retention | 30 days on Free, 90 days on Pro, 365 days on Business and kept indefinitely on Enterprise. A deleted job's deepLink returns 404. |
Usage
/api/v1/usagescope: readusage holds your counts and limits holds the plan limit for each. 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": 500, "items": 10000, "seats": null,
"apiKeys": 10, "packJobsPerMonth": 7500,
"packJobsIncludedPerMonth": 2500
},
"overage": { "centsPerJob": 8, "jobsThisMonth": 0 },
"solver": { "maxCartonsPerShipment": 50, "maxStrategies": 10 },
"api": { "rateLimitPerMinute": 300 },
"features": {
"webhooks": false, "erpIntegrations": true, "mcp": false,
"historyRetentionDays": 90
}
}
}Settings
/api/v1/settingsany keyWhat a pack uses when the request does not say.
200 OK
{
"data": {
"unitSystem": "IMPERIAL",
"defaultObjective": "LOWEST_RATE",
"carrier": "UPS",
"ratesConnected": true
}
}| Field | Holds |
|---|---|
unitSystem | The unit every length and mass is in. |
defaultObjective | The objective of a pack that names none. MIN_CARTONS when the stored value is not an objective. |
carrier | The carrier whose billing rules a pack uses when it names none. Null: billable weight uses the dimensional divisor. |
ratesConnected | True when a ShipStation API key, a ship-from postal code and a ship-from country are set, so a pack with a shipTo returns live rates. |
Webhooks
| Element | Value |
|---|---|
| Events | pack.completed · pack.failed |
| Envelope | { id, event, createdAt, data } |
| data | jobId reference externalId source status objective planObjective unitSystem summary unpacked unknownSkus rates, and cartons[] with index cartonSku cartonName grossWeight billableWeight utilization itemCount. planObjective is the objective that produced the job’s plan. On a LOWEST_RATE job it is the objective of the plan marked chosen. On any other job it equals objective. rates is the job’s live rates, as GET /jobs/:id returns them, or null. |
| Headers | X-CartonPackPro-Event: <event> and X-CartonPackPro-Signature: t=<unix>,v1=<hex>. |
| Signature | Hex HMAC-SHA256 of t + "." + body, keyed with the endpoint’s secret. Reject a t more than 300 seconds from now. |
| Delivery | One attempt per event, 5-second timeout, no retries. |
| Managed at | /integrations. Included with Business and Enterprise. |
import { createHmac, timingSafeEqual } from 'node:crypto'
// header: the X-CartonPackPro-Signature value. body: the raw request text.
function verify(secret, body, header) {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
const given = String(parts.v1 ?? '')
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}Inbound ERP integration
/api/integrations/:id/ordersPacks an order document your ERP sends, signed instead of bearer-authenticated.
| Element | Value |
|---|---|
| URL and secret | Each connection at /integrations shows its inbound URL and signing secret. |
| Signature | X-CartonPackPro-Signature, the same scheme as webhooks, keyed with the connection’s inbound secret over the exact bytes sent. |
| Body | The order document your ERP emits. NetSuite connections take a SuiteTalk REST sales order. |
| Ship-to | For live rates, a NetSuite connection reads the postal code from shippingAddress.zip and the country from shippingAddress.country, using its id, the two-letter code, when that field is a reference. Change either path with Ship-to postcode path or Ship-to country path on the connection’s NetSuite field mapping card, under Configuration → Integrations. |
| Response | The same as POST /orders/pack. |
| Plans | Included with Pro, Business and Enterprise. |
| Status | code | Cause |
|---|---|---|
| 401 | signature_missing | No signature header. |
| 401 | signature_invalid | Wrong secret, altered body, or t more than 300 seconds from now. |
| 404 | integration_connection_not_found | No connection has that id. |
| 403 | forbidden | The connection is disabled. |
| 402 | plan_limit_reached | The plan has no ERP integrations, or the monthly pack-job allowance is used. |
| 400 | order_not_understood | The document could not be read as an order. |
OpenAPI
/api/v1/openapi.jsonno authOpenAPI 3.1, built per request so its servers URL is the address you called.
Every schema, enum and request limit on this page, machine-readable. Plan limits are on GET /usage. 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" } }