Reference
ERP integration
One order document, two doors in, a signed webhook out, and a link back to the plan.
NormalizedOrder
interface NormalizedOrder {
externalId: string // your order number, 1–120 chars
source: string // provenance label, 1–60 chars
lines: NormalizedOrderLine[] // 1–500
shipTo?: { name?: string; country?: string; postalCode?: string }
requestedObjective?: 'MIN_CARTONS' | 'MIN_COST' | 'MIN_DIM_WEIGHT' | 'BEST_FIT'
metadata?: Record<string, string | number | boolean | null>
}
interface NormalizedOrderLine {
sku: string
quantity: number // whole, 1–10000
dimensions?: { length: number; width: number; height: number }
weight?: number // per unit
name?: string
fragile?: boolean
orientationLock?: 'FREE' | 'THIS_SIDE_UP' | 'FLAT' | 'UPRIGHT' | 'FIXED'
groupKey?: string
}| Field | Rule |
|---|---|
externalId | Stored on the pack job and indexed: GET /api/v1/jobs?externalId=SO-10482 finds the plan later without you keeping our id. |
source | Your provenance label. Distinct from the job’s own source column, which records the door: INTEGRATION for both routes below. |
lines[].sku | Resolved against your catalog, case-insensitively. Unknown and without inline dimensions, it is returned in unknownSkus and left out — the request still succeeds. |
lines[].groupKey | Kits and BOM sets stay in one carton where the geometry allows. Diagram. |
metadata | Scalars only. Carried onto the stored job for reconciliation. |
Push with an API key
/api/v1/orders/packscope: writeFor any system that can hold a secret and make an outbound HTTPS call.
curl -X POST "$CPP_URL/api/v1/orders/pack" \
-H "Authorization: Bearer $CPP_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "SO-10482",
"source": "netsuite",
"requestedObjective": "MIN_DIM_WEIGHT",
"lines": [
{ "sku": "GLS-TUMB-12", "quantity": 6 },
{ "sku": "ACC-CBL-6", "quantity": 2 }
],
"shipTo": { "country": "United States", "postalCode": "97035" },
"metadata": { "warehouse": "PDX-1" }
}'200 OK
{
"data": {
"jobId": "clv0x41770002s6a1k8m7n6p5",
"reference": "PJ-91B33E",
"externalId": "SO-10482",
"deepLink": "https://carton.example.com/jobs/clv0x41770002s6a1k8m7n6p5",
"unknownSkus": [],
"result": { /* PackResult — see the REST reference */ }
}
}Inbound hook
/api/integrations/:id/ordersAuthenticated by an HMAC over the body, not by a bearer token. Create the connection under /integrations.
# Body is whatever your system natively emits; the connection's # adapter translates it. Response envelope is identical to /orders/pack. curl -X POST "$CPP_URL/api/integrations/clv0y7t2m0003s6a1d4e5f6g7/orders" \ -H "Content-Type: application/json" \ -H "X-CartonPackPro-Signature: t=1790000000,v1=9f2c4b1d7e0a53c8…" \ --data-binary @order.json
| Status | code | Cause |
|---|---|---|
| 401 | signature_missing | No signature header. |
| 401 | signature_invalid | Wrong secret, re-serialized body, or a stale timestamp. |
| 404 | integration_connection_not_found | No such connection id. |
| 403 | forbidden | The connection is disabled. |
| 402 | plan_limit_reached | ERP integrations are a Pro entitlement. |
| 400 | order_not_understood | The adapter could not read the document. The message names the path. |
Deliberately outside /api/v1 and outside the OpenAPI document: it is scoped to one connection rather than to your organization, and its credential rotates without touching an API key.
Signature scheme
| Element | Definition |
|---|---|
| Header | X-CartonPackPro-Signature |
| Value | t=<unix seconds>,v1=<hex>, in that order. |
| Signed string | t + "." + rawBody. Sign the exact bytes you transmit; a body re-serialized after signing will not verify. |
| Algorithm | HMAC-SHA256, hex, keyed with that connection or endpoint secret. |
| Freshness | Rejected more than 300 seconds from now, which is what blocks a replay. |
| Comparison | Constant time, over equal-length buffers. Never with ===. |
import { createHmac, timingSafeEqual } from 'node:crypto'
/** Header value for a body you are about to send. */
export function sign(secret, rawBody) {
const t = Math.floor(Date.now() / 1000)
return 't=' + t + ',v1=' + createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex')
}
/** Verify one we sent you. Same scheme, opposite direction. */
export function verify(secret, rawBody, header) {
const parts = Object.fromEntries(String(header || '').split(',').map((kv) => kv.split('=')))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = Buffer.from(createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex'))
const actual = Buffer.from(String(parts.v1 || ''))
return expected.length === actual.length && timingSafeEqual(expected, actual)
}Outbound webhooks
| Event | Fires when | data carries |
|---|---|---|
pack.completed | A pack job finishes with anything packed. | jobId, reference, externalId, source, status, objective, unitSystem, summary, cartons[], unpacked[], unknownSkus[]. |
pack.failed | Nothing could be placed. | The same shape, with empty cartons and the reasons in unpacked[]. |
carton.low_stock | A carton is at or below its reorder point. | cartonId, cartonSku, cartonName, quantityOnHand, reorderPoint. |
POST https://erp.example.com/hooks/carton-pack-pro
Content-Type: application/json
User-Agent: CartonPackPro-Webhooks/1
X-CartonPackPro-Event: pack.completed
X-CartonPackPro-Signature: t=1790000102,v1=4d81b7c0e35a92f1…
{
"id": "0f7c1f2e-6f8a-4d1b-9d4e-2a5c8b31f0a7",
"event": "pack.completed",
"createdAt": "2026-09-28T14:26:42.004Z",
"data": {
"jobId": "clv0x41770002s6a1k8m7n6p5",
"reference": "PJ-91B33E",
"externalId": "SO-10482",
"source": "INTEGRATION",
"status": "SUCCESS",
"objective": "MIN_DIM_WEIGHT",
"unitSystem": "IMPERIAL",
"summary": { /* the full PackSummary */ },
"cartons": [
{
"index": 1,
"cartonSku": "RSC-12X9X6",
"cartonName": "RSC 12 × 9 × 6",
"grossWeight": 6.1,
"billableWeight": 6.42,
"utilization": 0.7103,
"itemCount": 8
}
],
"unpacked": [],
"unknownSkus": []
}
}| Delivery | Behavior |
|---|---|
| Entitlement | Pro. A downgrade stops delivery immediately. |
| Attempts | One, with a five-second deadline, never blocking the pack response. Each attempt is recorded with its status code. |
| Idempotency | Treat it as at-least-once. Key your handler on data.jobId, answer 2xx fast, work afterward. |
| Order of operations | Verify the signature before parsing. If it fails, answer 401 and touch nothing. |
| No link included | Build it from data.jobId. |
Deep links
https://<your-instance>/jobs/<jobId> data.deepLink # already absolute, from any pack response GET /api/v1/jobs?externalId=SO-10482 # lost the id? -> data[0].id
Absolute, built from APP_URL when it is set and from the request origin otherwise. The page is session-authenticated, so the link alone discloses nothing.
NetSuite
The NETSUITE adapter reads a SuiteTalk REST salesOrder document — what GET /record/v1/salesOrder/{id}?expandSubResources=true returns. No two accounts hold the SKU in the same field, so every read is a configurable path with the stock layout as its default.
| Setting | Default | Reads |
|---|---|---|
orderIdPath | tranId | Becomes externalId. |
linesPath | item.items | The expanded line sublist. |
skuPath | item.refName | The item’s own number, with any parent path removed. Point at a custcol_ field if your catalog is keyed by something else. |
quantityPath | quantity | Whole units only; a fraction is rejected. |
groupPath | blank | A line column that keeps matching lines in one carton. |
customerPath | entity.refName | shipTo.name. |
countryPath | shippingAddress.country | Name by default; add .id for the ISO code. |
postalCodePath | shippingAddress.zip | shipTo.postalCode. May be absent on an order with no structured address. |
objectivePath | blank | A body field holding an objective, per order. |
skipZeroQuantity | on | Drops closed, drop-shipped and description-only lines. |
accountId | blank | Echoed as metadata.netsuiteAccountId, so sandbox and production are distinguishable in history. |
A reference is unwrapped wherever it appears, so item and item.refName both resolve to the value a person reads off the screen.
item.refName is the item’s display name, and NetSuite prefixes it with the parent path — BEDROOM : Queen Bed for an item whose own number is Queen Bed. The adapter takes the last segment. If what is left still contains spaces it is a product name rather than a code, the order is refused, and the message says so: map skuPath to the line column holding the code your catalog is keyed by.
A reference is unwrapped wherever it appears, so item and item.refName both resolve to the value a person reads off the screen. A custbody_ or custcol_ field arrives as a bare value and is read as-is.
What runs inside NetSuite
Carton Pack Pro publishes an HTTP contract and holds itself to it, so the piece that sends a sales order out of your account can be whatever fits your governance budget and your deployment process — a workflow action, a scheduled search, or middleware you already run.
Three SuiteScripts cover the common case: a user event that adds a Pack this order button, a Suitelet that packs one order on demand, and the library they share. They were installed and run against a live account on 29 September 2026. There is no SuiteApp and no bundle — you upload the three files yourself, and the install guide is six steps.
Everything else on this page is held on every build: the document shapes above are sales orders read out of a live NetSuite account through SuiteTalk REST, and the adapter, the signature and the pack run are checked against them.
Whatever you write needs three things: the connection id in the URL, the signature header described above, and the salesOrder JSON as the body. That is the entire contract.
Anything else
A connection with provider GENERIC takes the smallest document that can describe an order and gets the same endpoint, signing, webhooks and deep links as the named providers. Unrecognized keys are ignored; the keys it reads are checked strictly. Its one setting, sourceLabel, is what shows in job history.
{ "externalId": "WMS-88213", "lines": [{ "sku": "NG-TUMBLER-20", "quantity": 2 }] }