Skip to content
On this page

Reference

REST API

Pack orders, manage cartons and items, and read pack history with an API key.

MethodPathPurpose
GET/healthChecks the server is up. No auth, no database.
POST/packRun the cartonizer on a list of lines.
POST/orders/packRun the cartonizer on a NormalizedOrder.
GET/cartonsList cartons.
POST/cartonsAdd a carton.
POST/cartons/bulkCreate or update up to 1,000 cartons.
GET/cartons/:idOne carton.
PATCH/cartons/:idAmend a carton.
DELETE/cartons/:idRemove a carton.
GET/itemsList items.
POST/itemsAdd an item.
POST/items/bulkCreate or update up to 1,000 items.
GET/items/:idOne item.
PATCH/items/:idAmend an item.
DELETE/items/:idRemove an item.
GET/jobsPack history, newest first.
GET/jobs/:idOne job with its full plan.
GET/usageUsage counts and plan limits.
GET/settingsPacking defaults and whether live rates are connected.
GET/openapi.jsonThe OpenAPI 3.1 document.

Base path /api/v1 on the address you sign in at.

Authentication

Every request
Authorization: Bearer cpp_live_7Qk2nR4vT8xW1yZ3aB5cD6eF9gH0jK2mN4pQ6rS8
RuleDetail
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.
Scopesread is required for every GET except /settings, which any key reads. write is required for every POST, PATCH and DELETE, including both pack routes.
OrganizationA key reaches only its own organization’s data.
RevocationImmediate. Jobs the key created stay attributed to it.

Response envelope

Success
{
  "data": { "…": "the payload" },
  "meta": { "page": 1, "pageSize": 50, "total": 120 }
}
Failure
{
  "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

StatuscodeCause
400invalid_jsonThe body is not JSON.
400invalid_bodyThe body is JSON but not an object.
400validation_failedOne or more fields are invalid; details has one entry per field.
400invalid_queryA query parameter is out of range or misspelled.
400empty_patchA PATCH body names no field to change.
401unauthorizedMissing, unknown or revoked token.
403forbiddenValid key, missing the scope the route requires.
404<resource>_not_foundNo such record in your organization. Another organization’s id also returns 404, not 403.
402plan_limit_reachedA plan limit is reached. details names the limit and plan.
409<resource>_sku_conflictThat SKU is taken. PATCH it instead.
429rate_limitedToo many requests from this key. Wait Retry-After seconds, then retry.
500internal_errorUnexpected.
Example: validation_failed
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

LimitBehavior
Requests per minuteToken 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 month50 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: retry later · 402: do not retry
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

Query parameters on every collection
Name and TypeReqDescription
page: integer ≥ 1noOne-based. Default 1.
pageSize: integer ≥ 1noDefault 50; anything over 200 becomes 200. meta shows the size used.
q: stringnoSubstring 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

POST/api/v1/packscope: write

Cartonize an order and store the plan. Consumes one pack job.

Body
Name and TypeReqDescription
lines: PackLine[]yes1 to 500 lines.
lines[].sku: stringyesCatalog SKU, or a new one when dimensions are inline. 1–64 characters: letters, digits, . _ - /, starting with a letter or digit.
lines[].quantity: integer 1–10000yesUnits of this SKU.
lines[].dimensions: { length, width, height }noOverrides the catalog for this line.
lines[].weight: numbernoPer unit. Send it whenever dimensions is sent.
lines[].dimensionUnit: in | ft | cm | m | mmnoThe unit of dimensions and nestIncrement, when it is not your organization’s. Converted before packing.
lines[].weightUnit: lb | oz | kg | gnoThe unit of weight and maxStackWeight, when it is not your organization’s. Converted before packing.
lines[].name: stringnoLabel; falls back to the catalog name.
lines[].fragile: booleannoOverrides the catalog flag for this line.
lines[].orientationLock: enumnoOverrides the catalog lock for this line.
lines[].nestIncrement: number 0–500noHeight 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–10000noMost units in one nest. Overrides the catalog for this line.
lines[].groupKey: stringnoLines with the same key go in one carton when possible; a split adds a warning.
lines[].lineRef: string ≤ 64noYour 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_RATEnoDefaults 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 ≤ 120noYour order number. externalId is a synonym and wins if both are sent.
cartonIds: string[] ≤ 200noPack only into these cartons.
options: PackOptionsnoSeven packing settings, capped by your plan. See the options table.
save: booleannoDefault true. False returns the plan and writes nothing.
shipTo: { postalCode, country, name? }noWhere the order goes, for live rates. country is a two-letter country code, e.g. US. Not stored.
Request
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"
      }
    ]
  }'
Response
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
  }
}
OutcomeWhat you get
Unknown SKUListed in unknownSkus and left out of the plan. Still a 200.
Did not fitIn result.unpacked with a reason, and result.success is false. Still a 200.
Nested unitsOne 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: falsejobId and reference are null, nothing is saved, and no pack job is used.
No live ratesrates is null: save is false, your organization has no ShipStation API key, the request has no shipTo, or no line packed.
ShipStation API failurerates.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 limitValue
Carrier-list requests1 per job
Estimate requestsAt most 1 per distinct carton and carrier
Requests at a time4 per carrier
Timeout per request, including reading the response body8 seconds
Deadline for all rating in the job15 seconds
Errors that stop the job's remaining requests401, 403, 429
Errors that drop only that carrier from the plans using that cartonAny other error, such as a 400, 5xx, timeout or network failure; the deadline
rates, on a LOWEST_RATE job
"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
    }
  ]
}
FieldHolds
kindESTIMATE: ShipStation API estimates, which may leave out fuel surcharges and other fees.
ratesOne entry per service that quoted every carton, summed over the cartons, cheapest first.
rates[].carrierUPS, 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.
errorWhy rates is empty, when it is.
warningsOne message naming the SKUs with no weight, which the rates count as 0 lb (0 kg on a metric organization).
comparisonLOWEST_RATE only. One entry per distinct plan, in the order its objectives ran. See the rules.
comparison[].objectivesThe objectives that produced this plan.
comparison[].rateThe 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[].totalcartonCost plus rate.amount, rounded to the cent.
comparison[].chosenTrue on the plan returned in result. result.objective names the objective that produced it.
comparison[].errorWhy the plan has no total. One of the values in the next table.
comparison[].errorWhen
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 secondsThe 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 rateThe 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 FedExEvery 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 rateThe 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 USDPlans 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 textRating that plan failed.

Pack an order

POST/api/v1/orders/packscope: write

Packs a whole NormalizedOrder with the same cartonizer and returns an absolute deepLink.

Body
Name and TypeReqDescription
externalId: string ≤ 120yesYour order number, stored on the job. GET /jobs?externalId= finds it.
source: string ≤ 60yesThe sending system, e.g. netsuite. Not stored: the job records INTEGRATION.
lines: PackLine[]yes1 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_RATEnoDefaults to your organization setting. LOWEST_RATE needs shipTo and a ShipStation API key, and compares rates with save true or false.
save: booleannoDefault 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: PackOptionsnoAs on POST /pack. On a metric organization, options.carrier fails the plan, with the reason in result.errors.
replaces: string ≤ 64 | nullnoThe 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? }noFor live rates, as on POST /pack. Not stored.
metadata: object of scalarsnoValidated, not stored.
Preview
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" }
    ]
  }'
Response
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 }
    ]
  }
}
FieldHolds
jobId · reference · deepLinkThe stored job, its reference and the absolute URL of its plan. Null when save is false.
result · rates · unknownSkusAs on POST /pack. result.carrier is the carrier whose rules set billable weight.
linesOne per request line, in request order.
lines[].specIdThe item id this line was packed under; its placements carry the same id in itemId. Null for a line in unknownSkus.
lines[].packedQuantityUnits of the line that were packed, out of quantity.
PlansIncluded with Pro, Business and Enterprise; other plans get a 402.

Cartons

GET/api/v1/cartonsscope: read

Paginated, SKU ascending. Filters: page, pageSize, q, active.

POST/api/v1/cartonsscope: write

Counts toward your plan's carton-type limit. A duplicate SKU returns 409.

Field groupRule
Requiredsku name type innerLength innerWidth innerHeight maxWeight tareWeight cost padding maxBulge fillFactor
BooleansWhen absent, active is read as true and flexible as false.
Cross-fieldEach inner dimension must be more than twice padding, and each outer dimension at least its inner one.
Types and rangesPer field in the column reference and in the OpenAPI document.
Request
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
  }'
Response
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"
  }
}
GET/api/v1/cartons/:idscope: read

404 when the id is not yours.

PATCH/api/v1/cartons/:idscope: write

Send only what changes; the updated carton must pass the same rules as a create.

DELETE/api/v1/cartons/:idscope: write

Returns 204. For a size you may use again, set active: false instead.

Items

GET/api/v1/itemsscope: read

Filters: page, pageSize, q, active, category.

POST/api/v1/itemsscope: write

Counts toward your plan's item limit. A duplicate SKU returns 409.

Field groupRule
Requiredsku name length width height weight maxStackWeight orientationLock
BooleansWhen absent, stackable and active are read as true and fragile as false.
Cross-fieldnestIncrement must be less than height, and maxNestQuantity needs a nestIncrement above 0.
Types and rangesThe column reference
GET/api/v1/items/:idscope: read
PATCH/api/v1/items/:idscope: write

Clearing nestIncrement, with null or 0, clears maxNestQuantity too.

DELETE/api/v1/items/:idscope: write

Historic jobs keep their own snapshot of the SKU and its dimensions.

Bulk load

POST/api/v1/cartons/bulk · /api/v1/items/bulkscope: write

Up to 1,000 rows, validated individually, written in one transaction.

Body
Name and TypeReqDescription
mode: "CREATE_ONLY" | "UPSERT"yesCREATE_ONLY skips SKUs you already have; UPSERT updates them.
rows: CartonInput[] | ItemInput[]yesWhole records — the same shape the POST routes take.
Request
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
      }
    ]
  }'
Response
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" }
    ]
  }
}
BehaviorDetail
Per rowcreated, updated, skipped or failed, with the submitted index.
Repeated SKUThe first row with that SKU is applied; later rows with it are skipped and name that row.
Plan limitChecked against your total after the load, so updating SKUs you already have does not count toward it.
Over 1,000 rowsUse the importer. Column reference.

Pack jobs

GET/api/v1/jobsscope: read

Summaries only, newest first. The full plan is on GET /api/v1/jobs/:id.

Query
Name and TypeReqDescription
externalId: stringnoExact match on your own order id.
q: stringnoMatches our reference and your external id.
status: SUCCESS | PARTIAL | FAILEDnoFilter by outcome.
source: WEB | API | MCP | INTEGRATIONnoWhere the run came from.
page · pageSize: integernoSame as every collection.
Request
curl "$CPP_URL/api/v1/jobs?externalId=SO-10422" \
  -H "Authorization: Bearer $CPP_KEY"
Response
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 }
}
GET/api/v1/jobs/:idscope: read

Adds deepLink, the requested lines, and the full stored result.

FieldMeaning
deepLinkFull URL of the visual plan in the app.
linesThe requested lines as packed, copied at pack time so later catalog edits do not change them. Sorted by SKU.
lines[].lineRef · lines[].specIdThe 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.
resultThe PackResult exactly as the run returned it.
ratesThe live rates fetched when the job was packed, or null.
Retention30 days on Free, 90 days on Pro, 365 days on Business and kept indefinitely on Enterprise. A deleted job's deepLink returns 404.

Usage

GET/api/v1/usagescope: read

usage holds your counts and limits holds the plan limit for each. null means unlimited.

Response
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

GET/api/v1/settingsany key

What a pack uses when the request does not say.

Response
200 OK

{
  "data": {
    "unitSystem": "IMPERIAL",
    "defaultObjective": "LOWEST_RATE",
    "carrier": "UPS",
    "ratesConnected": true
  }
}
FieldHolds
unitSystemThe unit every length and mass is in.
defaultObjectiveThe objective of a pack that names none. MIN_CARTONS when the stored value is not an objective.
carrierThe carrier whose billing rules a pack uses when it names none. Null: billable weight uses the dimensional divisor.
ratesConnectedTrue 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

ElementValue
Eventspack.completed · pack.failed
Envelope{ id, event, createdAt, data }
datajobId 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.
HeadersX-CartonPackPro-Event: <event> and X-CartonPackPro-Signature: t=<unix>,v1=<hex>.
SignatureHex HMAC-SHA256 of t + "." + body, keyed with the endpoint’s secret. Reject a t more than 300 seconds from now.
DeliveryOne attempt per event, 5-second timeout, no retries.
Managed at/integrations. Included with Business and Enterprise.
Verify a signature (Node)
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

POST/api/integrations/:id/orders

Packs an order document your ERP sends, signed instead of bearer-authenticated.

ElementValue
URL and secretEach connection at /integrations shows its inbound URL and signing secret.
SignatureX-CartonPackPro-Signature, the same scheme as webhooks, keyed with the connection’s inbound secret over the exact bytes sent.
BodyThe order document your ERP emits. NetSuite connections take a SuiteTalk REST sales order.
Ship-toFor 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.
ResponseThe same as POST /orders/pack.
PlansIncluded with Pro, Business and Enterprise.
StatuscodeCause
401signature_missingNo signature header.
401signature_invalidWrong secret, altered body, or t more than 300 seconds from now.
404integration_connection_not_foundNo connection has that id.
403forbiddenThe connection is disabled.
402plan_limit_reachedThe plan has no ERP integrations, or the monthly pack-job allowance is used.
400order_not_understoodThe document could not be read as an order.

OpenAPI

GET/api/v1/openapi.jsonno auth

OpenAPI 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

GET/api/v1/healthno auth

For load balancers. Touches no data, so it stays 200 through a database outage.

Response
{ "data": { "status": "ok", "version": "1.0.0", "time": "2026-09-28T09:14:02.511Z" } }
Cartonization REST API · Carton Pack Pro