Skip to content

Reference

ERP integration

One order document, two doors in, a signed webhook out, and a link back to the plan.

NormalizedOrder

Every adapter produces this
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
}
FieldRule
externalIdStored on the pack job and indexed: GET /api/v1/jobs?externalId=SO-10482 finds the plan later without you keeping our id.
sourceYour provenance label. Distinct from the job’s own source column, which records the door: INTEGRATION for both routes below.
lines[].skuResolved against your catalog, case-insensitively. Unknown and without inline dimensions, it is returned in unknownSkus and left out — the request still succeeds.
lines[].groupKeyKits and BOM sets stay in one carton where the geometry allows. Diagram.
metadataScalars only. Carried onto the stored job for reconciliation.

Push with an API key

POST/api/v1/orders/packscope: write

For any system that can hold a secret and make an outbound HTTPS call.

Request
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" }
  }'
Response
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

Order systemsales order savedPOST · signedInbound hook/api/integrations/:id/ordersverify HMAC + timestampconnection enabled?plan allows ERP?AdapterNETSUITEGENERIC→ NormalizedOrderSolverpack job stored,webhook fired200 · jobId, reference, externalId, deepLink, resultNo API key on this path — the connection id says which link it is, the signature proves it.401 on a bad signature,403 disabled, 402 on Free.
One connection, one secret, revocable on its own.
POST/api/integrations/:id/orders

Authenticated by an HMAC over the body, not by a bearer token. Create the connection under /integrations.

Request
# 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
StatuscodeCause
401signature_missingNo signature header.
401signature_invalidWrong secret, re-serialized body, or a stale timestamp.
404integration_connection_not_foundNo such connection id.
403forbiddenThe connection is disabled.
402plan_limit_reachedERP integrations are a Pro entitlement.
400order_not_understoodThe 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

Signt = 1790000000raw body, byte for bytejoined by a periodHMAC-SHA256( secret, "1790000000.{…}" ) → hexX-CartonPackPro-Signaturet=1790000000,v1=9f2c4b…Verify1Reject if |now − t| > 300 s.2Recompute the HMAC over t + "." + raw body.3Compare in constant time, never with ===.4Only then parse the JSON.The same scheme runs in both directions,so one helper covers both.
Identical in both directions, so one helper covers sending and receiving.
ElementDefinition
HeaderX-CartonPackPro-Signature
Valuet=<unix seconds>,v1=<hex>, in that order.
Signed stringt + "." + rawBody. Sign the exact bytes you transmit; a body re-serialized after signing will not verify.
AlgorithmHMAC-SHA256, hex, keyed with that connection or endpoint secret.
FreshnessRejected more than 300 seconds from now, which is what blocks a replay.
ComparisonConstant time, over equal-length buffers. Never with ===.
Node — sign and verify
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

EventFires whendata carries
pack.completedA pack job finishes with anything packed.jobId, reference, externalId, source, status, objective, unitSystem, summary, cartons[], unpacked[], unknownSkus[].
pack.failedNothing could be placed.The same shape, with empty cartons and the reasons in unpacked[].
carton.low_stockA carton is at or below its reorder point.cartonId, cartonSku, cartonName, quantityOnHand, reorderPoint.
pack.completed
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": []
  }
}
DeliveryBehavior
EntitlementPro. A downgrade stops delivery immediately.
AttemptsOne, with a five-second deadline, never blocking the pack response. Each attempt is recorded with its status code.
IdempotencyTreat it as at-least-once. Key your handler on data.jobId, answer 2xx fast, work afterward.
Order of operationsVerify the signature before parsing. If it fails, answer 401 and touch nothing.
No link includedBuild it from data.jobId.
Pack responsedata.deepLinkOrder recordhyperlink custom fieldA person clicksfrom the order screen/jobs/:id3D plan,step scrubber,printable stepsLost the id? GET /api/v1/jobs?externalId=SO-10422 → data[0].idThe page is session-authenticated: the link alone discloses nothing.
Store deepLink on the order and the button is a hyperlink field.
Pattern
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.

SettingDefaultReads
orderIdPathtranIdBecomes externalId.
linesPathitem.itemsThe expanded line sublist.
skuPathitem.refNameThe item’s own number, with any parent path removed. Point at a custcol_ field if your catalog is keyed by something else.
quantityPathquantityWhole units only; a fraction is rejected.
groupPathblankA line column that keeps matching lines in one carton.
customerPathentity.refNameshipTo.name.
countryPathshippingAddress.countryName by default; add .id for the ISO code.
postalCodePathshippingAddress.zipshipTo.postalCode. May be absent on an order with no structured address.
objectivePathblankA body field holding an objective, per order.
skipZeroQuantityonDrops closed, drop-shipped and description-only lines.
accountIdblankEchoed 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.

The whole required contract
{ "externalId": "WMS-88213", "lines": [{ "sku": "NG-TUMBLER-20", "quantity": 2 }] }
ERP integration · Carton Pack Pro