Official Python SDK for the CartonPilot 3D bin-packing / cartonization REST API.
The SDK is a thin pass-through client: requests are plain dicts shaped like the documented API body, and responses are the parsed JSON as dicts. Nothing is renamed or remodeled, so every API feature works without waiting for an SDK update.
pip install cartonpilotRequires Python >= 3.9 and requests >= 2.28. You will need an API key; the
free plan needs no credit card.
from cartonpilot import CartonPilot
client = CartonPilot(api_key="sk_live_...") # base_url defaults to https://cartonpilot.com
result = client.optimize({
"boxes": [
{
"id": "small-box",
"name": "Small Shipping Box",
"dimensions": {"length": 10, "width": 8, "height": 6},
"weightCapacity": 20,
"weight": 0.4, # tare weight of the empty box
"cost": 5.99,
},
{
"id": "medium-box",
"name": "Medium Shipping Box",
"dimensions": {"length": 14, "width": 12, "height": 10},
"weightCapacity": 35,
"cost": 8.99,
},
],
"items": [
{
"id": "item-001",
"name": "Product A",
"dimensions": {"length": 4, "width": 3, "height": 2},
"weight": 1.5,
"quantity": 2,
},
],
"options": {"objective": "fewest-parcels", "allowRotation": True},
})
for shipment in result["data"]["shipments"]:
print(shipment["box"]["name"], "->", len(shipment["packedItems"]), "items")
print(result["data"]["summary"]) # totalShipments, totalCost, averageUtilization, ...
print(result["data"]["unpackedItems"]) # items that could not be packed, with reasonsInstead of inline boxes you can pass a saved box set: "boxSetKey": "bs_...". Items can also reference a saved item catalog by SKU — pass "catalogKey": "ic_..." and use {"sku": "WIDGET-1", "quantity": 2} entries in items.
Supported options.objective values: "fewest-parcels" (default), "lowest-cost", "lowest-billable-weight" (requires options.dimDivisor), "lowest-invoice-cost" (requires options.rateCardKey and a zone), "fastest".
Send up to 50 orders synchronously with orders instead of items:
result = client.optimize({
"boxSetKey": "bs_abc123xyz456",
"orders": [
{"orderId": "ORD-001", "items": [{"id": "item-001", "dimensions": {"length": 4, "width": 3, "height": 2}, "weight": 1.5}]},
{"orderId": "ORD-002", "items": [{"id": "item-002", "dimensions": {"length": 6, "width": 5, "height": 3}, "weight": 2.8}]},
],
"options": {"objective": "fewest-parcels"},
})
for order in result["results"]: # batch responses: {success, batch, results, summary, metadata}
print(order["orderId"], "ok" if order["success"] else order.get("error"))
print(result["summary"]) # totalOrders, successfulOrders, totalBoxesUsed, totalCost, ...Larger batches (up to 500 orders / 5000 items) run in the background with "async": True. The API returns 202 Accepted immediately with a job id. Note that async is a Python keyword, so it appears as a string dict key — the SDK passes your request through untouched.
accepted = client.optimize({
"boxSetKey": "bs_abc123xyz456",
"catalogKey": "ic_def789uvw012",
"orders": [
{"orderId": "ORD-001", "items": [{"sku": "WIDGET-1", "quantity": 2}]},
{"orderId": "ORD-002", "items": [{"sku": "GADGET-7"}]},
],
"async": True,
"webhook": { # optional: get notified instead of polling
"url": "https://example.com/webhooks/cartonpilot",
"secret": "whsec_my_signing_secret",
},
"options": {"objective": "lowest-billable-weight", "dimDivisor": 139},
})
print(accepted["jobId"], accepted["status"], accepted["statusUrl"])job = client.wait_for_batch_job(accepted["jobId"], poll_interval=2.0, timeout=600.0)
if job["status"] == "completed":
batch_result = job["result"] # same shape as a sync batch response
else:
print("Job failed:", job.get("error"))wait_for_batch_job raises CartonPilotTimeoutError if the job hasn't reached completed or failed within timeout seconds. You can also call client.get_batch_job(job_id) for a single poll, or client.list_batch_jobs() for your 20 most recent jobs.
When you include a webhook field, CartonPilot POSTs JSON to your URL on completion or failure:
{
"event": "batch_job.completed",
"jobId": "…",
"status": "completed",
"statusUrl": "/api/v1/batch-jobs/…",
"totalOrders": 2,
"totalItems": 3,
"summary": { "…": "…" },
"timestamp": "2026-07-26T12:00:00.000Z"
}(event is "batch_job.failed" with an "error" field on failure.) Deliveries include X-CartonPilot-Event and X-CartonPilot-Job-Id headers, and — when you provided a secret — an X-CartonPilot-Signature: sha256=<hex> header (HMAC-SHA256 of the raw body). Always verify it:
# Flask
from flask import Flask, request, abort
from cartonpilot import verify_webhook_signature
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_my_signing_secret"
@app.post("/webhooks/cartonpilot")
def cartonpilot_webhook():
signature = request.headers.get("X-CartonPilot-Signature", "")
if not verify_webhook_signature(request.get_data(), signature, WEBHOOK_SECRET):
abort(401)
event = request.get_json()
if event["event"] == "batch_job.completed":
... # fetch full results via client.get_batch_job(event["jobId"])
return "", 204# FastAPI
from fastapi import FastAPI, Header, HTTPException, Request
from cartonpilot import verify_webhook_signature
app = FastAPI()
WEBHOOK_SECRET = "whsec_my_signing_secret"
@app.post("/webhooks/cartonpilot")
async def cartonpilot_webhook(request: Request, x_cartonpilot_signature: str = Header("")):
raw_body = await request.body()
if not verify_webhook_signature(raw_body, x_cartonpilot_signature, WEBHOOK_SECRET):
raise HTTPException(status_code=401)
event = await request.json()
...
return {"ok": True}Pass idempotency_key to safely retry optimize calls (e.g. after a network error). Replays of the same key + payload return the stored response without consuming quota; reusing a key with a different payload fails with code idempotency_conflict (HTTP 409). Keys expire after 24 hours.
result = client.optimize(request_body, idempotency_key="order-batch-2026-07-26-001")Upload your negotiated carrier rates (zone × weight table, DIM divisor, overweight surcharge) via the /api/rate-cards endpoints, then optimize against your actual freight costs:
result = client.optimize({
"boxSetKey": "bs_abc123xyz456",
"catalogKey": "ic_def789uvw012",
"orders": [
{"orderId": "ORD-001", "zone": "4", "items": [{"sku": "WIDGET-1", "quantity": 2}]},
{"orderId": "ORD-002", "zone": "7", "items": [{"sku": "GADGET-7"}]},
],
"options": {
"objective": "lowest-invoice-cost",
"rateCardKey": "rc_ups2026ground",
# For single orders (or as a batch-wide default) use "zone" here instead
# of per-order zones.
},
})Each shipment then carries a billing block with actualWeight, dimWeight, billableWeight, and an invoice estimate (zone, baseRate, overweightFee, total, currency); the summary includes totalInvoiceCost and currency. With just options.dimDivisor (no rate card), you get billable weights and a summary totalBillableWeight.
Non-2xx responses raise CartonPilotError carrying the structured error envelope:
from cartonpilot import CartonPilot, CartonPilotError, CartonPilotTimeoutError
try:
result = client.optimize(request_body)
except CartonPilotError as err:
print(err.status) # HTTP status, e.g. 400
print(err.code) # machine code, e.g. "invalid_request", "quota_exceeded"
print(err.message) # human-readable message
print(err.request_id) # server request id for support
if err.code == "invalid_request" and err.issues:
for issue in err.issues:
print(f" {issue['path']}: {issue['message']}")
print(err.body) # full parsed error body (extra fields vary by code)Error codes: invalid_request, authentication_failed, endpoint_not_allowed, algorithm_not_allowed, feature_not_available, limit_exceeded, quota_exceeded, idempotency_conflict, not_found, internal_error. CartonPilotTimeoutError (a CartonPilotError subclass) is raised only by wait_for_batch_job.