Quickstart
Submit a small job, poll it to completion, and read back exactly where each box landed. The whole round trip takes a few minutes. You'll need an API key (Authentication).
Prefer to start from working code? Ready-to-run clients further down has these same three steps as one file you can run, in Python or Node, and A mixed palletizing job after it does the same for pallet building.
Here is the whole shape of it before you write any code. Steps 1 to 3 below run down the middle, and step 4 is the two optional branches hanging off the result.
the one thing to notice early: requesting instructions does not hand you a document. It starts a second job with its own id, and you poll that one just like the first.
1. Submit a job
We'll define one carrier (a pallet, inline) and one cargo line, and let the solver place it.
carriers is the available supply and cargo is the demand. A carrier's quantity is how many instances of it the solver may use. It is omitted here, which means unlimited: the solver decides how many pallets it needs, and the result holds only the ones it actually used. Send an explicit number instead and the result holds exactly that many carriers, some of which may be empty (see Anatomy of a job). Each cargo line's externalId is your own label for that line: the API echoes it back, but the reliable way to match a result back to this line is the itemId the API assigns, which appears on its placements (see Core concepts).
curl -X POST https://api.loadoptimizer.ai/v2/jobs \
-H "X-Api-Key: ck_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-001" \
-d '{
"units": { "length": "m", "weight": "kg" },
"carriers": [{ "externalId": "EUR-PALLET", "geometry": { "type": "box", "dimensionX": 1.2, "dimensionY": 0.8, "dimensionZ": 1.5 } }],
"cargo": [{ "externalId": "SKU-123", "description": "Box A", "amount": 2, "weight": 5.0,
"geometry": { "type": "box", "dimensionX": 0.3, "dimensionY": 0.4, "dimensionZ": 0.2 } }]
}'
The response is 202 Accepted, with a Location header pointing at the new job:
{ "jobId": "01975cba-67b9-7b7c-a2f1-3d29e4a01c44", "status": "queued" }
The Idempotency-Key header is optional (max 200 characters) but worth sending on every submit: retry the exact same request with the same key and you get back the original job instead of a duplicate. Reusing the key with a different body is rejected with an empty 409. See Errors & troubleshooting.
2. Poll until it's done
curl https://api.loadoptimizer.ai/v2/jobs/01975cba-67b9-7b7c-a2f1-3d29e4a01c44 \
-H "X-Api-Key: ck_live_..."
{ "jobId": "01975cba-67b9-7b7c-a2f1-3d29e4a01c44", "status": "running", "percentage": 50, "queuePosition": null, "errorCategory": null, "error": null, "name": null, "createdAt": "2026-08-19T08:12:33.412+00:00" }
Poll on a fixed interval (every couple of seconds is plenty; there's no benefit to polling faster) until status is succeeded (or a terminal failed/cancelled). Don't assume it's still queued right after submitting: once it leaves the queue and starts calculating, the status moves straight to running. If you hit a 429, back off on your own schedule and retry; see Errors & troubleshooting.
3. Get the result
Before the job succeeds, this returns 404 with code: "result_not_ready". Once status is succeeded:
curl https://api.loadoptimizer.ai/v2/jobs/01975cba-67b9-7b7c-a2f1-3d29e4a01c44/result \
-H "X-Api-Key: ck_live_..."
{
"jobId": "01975cba-67b9-7b7c-a2f1-3d29e4a01c44",
"status": "succeeded",
"summary": { "carriersUsed": 1, "itemsPlaced": 2, "itemsUnplaced": 0, "totalWeight": 10.0 },
"carriers": [
{
"externalId": "EUR-PALLET",
"name": null,
"instance": 1,
"geometry": { "box": { "dimensionX": 1.2, "dimensionY": 0.8, "dimensionZ": 1.5 } },
"kpis": { "volumeUtilization": 3.3, "loadedWeight": 10.0, "loadedVolume": 0.048, "itemCount": 2 },
"placements": [
{
"x": 0.0, "y": 0.0, "z": 0.0,
"sizeX": 0.3, "sizeY": 0.4, "sizeZ": 0.2,
"orientationRx": 0, "orientationRy": 0, "orientationRz": 0,
"weight": 5.0,
"geometry": { "box": { "dimensionX": 0.3, "dimensionY": 0.4, "dimensionZ": 0.2 } },
"description": "Box A", "color": null, "externalId": "SKU-123", "category": null,
"group": [], "itemId": "01975cba-6a1f-7c3d-9e04-5b71c8f2a613"
},
{
"x": 0.3, "y": 0.0, "z": 0.0,
"sizeX": 0.4, "sizeY": 0.3, "sizeZ": 0.2,
"orientationRx": 0, "orientationRy": 0, "orientationRz": 90,
"weight": 5.0,
"geometry": { "box": { "dimensionX": 0.3, "dimensionY": 0.4, "dimensionZ": 0.2 } },
"description": "Box A", "color": null, "externalId": "SKU-123", "category": null,
"group": [], "itemId": "01975cba-6a1f-7c3d-9e04-5b71c8f2a613"
}
],
"category": null
}
],
"unplacedItems": [],
"units": { "length": "m", "weight": "kg", "volume": "m3", "area": "m2", "ldm": "m" },
"name": null,
"createdAt": "2026-08-19T08:12:33.412+00:00"
}
Both units of SKU-123 fit on the one pallet, so itemsUnplaced is 0 and both placements share the same itemId: it's the same cargo line. The second one is rotated 90° about Z: sizeX/sizeY swap relative to the first, and x/y/z is still the box's minimum corner, not a pre-rotation corner. See Reading a placement if that's not familiar yet.
4. (Optional) instructions & sharing
- Loading-instruction PDFs:
POST /v2/jobs/{id}/instructionsqueues a second, derived job: poll that job's id, thenGET /v2/jobs/{newId}/instructionsto download one carrier's PDF at a time. - Shareable 3D viewer:
POST /v2/jobs/{id}/sharemints a read-only link you can hand to anyone; no API key is needed to open it.
5. Ready-to-run clients
The same three calls as one file you can run. Copy either listing below into a file of your own: both work on a stock installation with no packages added, the Python one on urllib.request and the Node one on the built-in fetch (Node 18 or newer).
Both take your key in whichever of three ways suits you. The shortest is to pass it as the first argument, assuming you saved the listings as quickstart.py and quickstart.mjs:
python quickstart.py ck_live_...
node quickstart.mjs ck_live_...
An argument wins over API_KEY, and API_KEY wins over the environment, so the most specific thing you gave it is the one that runs.
Each script walks the same three steps as this page, in order:
- Submit:
POST /jobswith the job body defined at the top of the file, then prints thejobIdthat comes back. - Poll:
GET /jobs/{id}every two seconds, printing the status each time, until the job issucceededor ends asfailedorcancelled. - Read the result:
GET /jobs/{id}/result, then prints the totals and, for every carrier, where each box landed and how it was rotated.
Anything the API refuses is printed as a single message and the script exits with code 1.
#!/usr/bin/env python3
"""Quickstart client for the Load Optimizer API: submit a job, poll it, read the result.
Needs Python 3.9 or newer and nothing installed. Give it your key in whichever way suits you:
python quickstart.py ck_live_... as the first argument
API_KEY = "ck_live_..." filled in below
LOADOPTIMIZER_API_KEY=ck_live_... in the environment
An argument wins over API_KEY, which wins over the environment.
"""
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE_URL = "https://api.loadoptimizer.ai/v2"
POLL_SECONDS = 2
GIVE_UP_AFTER_SECONDS = 600
# Your key. Leave it empty to take it from the first argument or from the environment. Filling it
# in here is the least typing while you try things out, and also the easiest one to commit by
# accident; the environment is the only one of the three that stays out of both this file and your
# shell history.
API_KEY = ""
JOB = {
"units": {"length": "m", "weight": "kg"},
"carriers": [{"externalId": "EUR-PALLET",
"geometry": {"type": "box", "dimensionX": 1.2, "dimensionY": 0.8,
"dimensionZ": 1.5}}],
"cargo": [{"externalId": "SKU-123", "description": "Box A", "amount": 2, "weight": 5.0,
"geometry": {"type": "box", "dimensionX": 0.3, "dimensionY": 0.4,
"dimensionZ": 0.2}}],
}
class ApiError(Exception):
"""A refused call or a job that ended badly. The message is ready to log."""
def api_key():
"""The key: first argument, else API_KEY above, else the environment."""
if len(sys.argv) > 1:
return sys.argv[1]
return API_KEY or os.environ.get("LOADOPTIMIZER_API_KEY", "")
def call(method, path, body=None, extra_headers=None):
key = api_key()
if not key:
raise ApiError("No API key. Pass it as the first argument, fill in API_KEY in this file, "
"or set LOADOPTIMIZER_API_KEY.")
request = urllib.request.Request(
BASE_URL + path,
data=json.dumps(body).encode("utf-8") if body is not None else None,
method=method,
headers={"X-Api-Key": key, "Content-Type": "application/json",
**(extra_headers or {})})
try:
with urllib.request.urlopen(request) as answer:
return json.loads(answer.read() or b"null")
except urllib.error.HTTPError as refused:
# HTTPError first: it is a subclass of URLError, so the other order would report every
# answer the API gave as if nothing had answered at all.
raise ApiError(explain(refused)) from refused
except urllib.error.URLError as unreachable:
raise ApiError(f"Could not reach {BASE_URL}: {unreachable.reason}") from unreachable
def explain(refused):
"""Read the problem+json body. A few answers (401, 409) have an empty body by design."""
raw = refused.read().decode("utf-8", "replace").strip()
if not raw:
return f"HTTP {refused.code} with an empty body."
try:
problem = json.loads(raw)
except ValueError:
return f"HTTP {refused.code}: {raw[:400]}"
lines = [f"HTTP {problem.get('status', refused.code)} "
f"{problem.get('code', '')}: {problem.get('title', '')}".rstrip()]
for item in problem.get("errors", []):
lines.append(f" {item.get('pointer', '?')}: {item.get('message', '')}")
return "\n".join(lines)
def idempotency_key(job):
"""Derive the key from the body: resend the same job and you get the original back,
change the job and the key changes with it. Reusing one key for a different body is
refused with an empty 409, and deriving it is how you never hit that."""
digest = hashlib.sha256(json.dumps(job, sort_keys=True).encode("utf-8")).hexdigest()
return f"quickstart-{digest[:32]}"
def wait_for(job_id):
"""Poll on a fixed interval until the job reaches a terminal state."""
deadline = time.monotonic() + GIVE_UP_AFTER_SECONDS
while True:
status = call("GET", f"/jobs/{job_id}")
state = status["status"]
print(f" {state} {status.get('percentage') or 0}%")
if state == "succeeded":
return
if state in ("failed", "cancelled"):
# error.code is the closed set to branch on. errorCategory is diagnostic only:
# log it, never switch on it.
ended = status.get("error") or {}
raise ApiError(f"job {state}: {ended.get('code', 'unknown')}: "
f"{ended.get('message', '')} "
f"[category {status.get('errorCategory')}]")
if time.monotonic() > deadline:
raise ApiError(f"job {job_id} still {state} after "
f"{GIVE_UP_AFTER_SECONDS}s, giving up.")
time.sleep(POLL_SECONDS)
def report(result):
totals = result["summary"]
print(f"{totals['carriersUsed']} carrier(s), {totals['itemsPlaced']} placed, "
f"{totals['itemsUnplaced']} unplaced, {totals['totalWeight']} "
f"{result['units']['weight']} in total")
for carrier in result["carriers"]:
kpis = carrier["kpis"]
print(f" {carrier['externalId']} #{carrier['instance']}: "
f"{kpis['volumeUtilization']:.1f}% of volume, {kpis['itemCount']} item(s)")
for placed in carrier["placements"]:
print(f" {placed['externalId']} at ({placed['x']}, {placed['y']}, "
f"{placed['z']}) rz={placed['orientationRz']} itemId={placed['itemId']}")
for missing in result["unplacedItems"]:
print(f" unplaced: {missing['amount']} of {missing['description']} "
f"(itemId {missing['itemId']})")
def main():
try:
accepted = call("POST", "/jobs", JOB,
{"Idempotency-Key": idempotency_key(JOB)})
print(f"submitted {accepted['jobId']} ({accepted['status']})")
wait_for(accepted["jobId"])
report(call("GET", f"/jobs/{accepted['jobId']}/result"))
except ApiError as problem:
print(problem, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env node
/**
* Quickstart client for the Load Optimizer API: submit a job, poll it, read the result.
*
* Needs Node 18 or newer and nothing installed. Give it your key in whichever way suits you:
*
* node quickstart.mjs ck_live_... as the first argument
* const API_KEY = "ck_live_..." filled in below
* LOADOPTIMIZER_API_KEY=ck_live_... in the environment
*
* An argument wins over API_KEY, which wins over the environment.
*/
import { createHash } from "node:crypto";
const BASE_URL = "https://api.loadoptimizer.ai/v2";
const POLL_SECONDS = 2;
const GIVE_UP_AFTER_SECONDS = 600;
// Your key. Leave it empty to take it from the first argument or from the environment. Filling it
// in here is the least typing while you try things out, and also the easiest one to commit by
// accident; the environment is the only one of the three that stays out of both this file and your
// shell history.
const API_KEY = "";
const JOB = {
units: { length: "m", weight: "kg" },
carriers: [{ externalId: "EUR-PALLET",
geometry: { type: "box", dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.5 } }],
cargo: [{ externalId: "SKU-123", description: "Box A", amount: 2, weight: 5.0,
geometry: { type: "box", dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2 } }],
};
/** A refused call or a job that ended badly. The message is ready to log. */
class ApiError extends Error {}
/** The key: first argument, else API_KEY above, else the environment. */
const apiKey = () => process.argv[2] || API_KEY || process.env.LOADOPTIMIZER_API_KEY || "";
async function call(method, path, body, extraHeaders = {}) {
const key = apiKey();
if (!key) {
throw new ApiError("No API key. Pass it as the first argument, fill in API_KEY in this file, " +
"or set LOADOPTIMIZER_API_KEY.");
}
let answer;
try {
answer = await fetch(BASE_URL + path, {
method,
headers: { "X-Api-Key": key, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (unreachable) {
// fetch rejects only when nothing answered at all: no route, no port, no such name. The outer
// error is a bare "fetch failed", so the reason sits one or two levels down: in cause, or in
// cause.errors when several addresses were tried, which is the usual case since an IPv6 and an
// IPv4 attempt both fail and get bundled into an AggregateError with no message of its own.
const cause = unreachable.cause;
const reason = cause?.errors?.[0]?.message ?? cause?.message ?? unreachable.message;
throw new ApiError(`Could not reach ${BASE_URL}: ${reason}`);
}
// fetch resolves on a 4xx as happily as on a 2xx, so check this yourself: without it you
// parse an error body as if it were a result.
if (!answer.ok) throw new ApiError(await explain(answer));
const raw = await answer.text();
return raw ? JSON.parse(raw) : null;
}
/** Read the problem+json body. A few answers (401, 409) have an empty body by design. */
async function explain(answer) {
const raw = (await answer.text()).trim();
if (!raw) return `HTTP ${answer.status} with an empty body.`;
let problem;
try {
problem = JSON.parse(raw);
} catch {
return `HTTP ${answer.status}: ${raw.slice(0, 400)}`;
}
const lines = [`HTTP ${problem.status ?? answer.status} ${problem.code ?? ""}: ` +
`${problem.title ?? ""}`.trimEnd()];
for (const item of problem.errors ?? []) {
lines.push(` ${item.pointer ?? "?"}: ${item.message ?? ""}`);
}
return lines.join("\n");
}
// Derive the key from the body: resend the same job and you get the original back, change the
// job and the key changes with it. Reusing one key for a different body is refused with an
// empty 409, and deriving it is how you never hit that. JSON.stringify has no key ordering, so
// this client and the Python one produce different keys for the same job. That is fine: a key
// only has to be stable per client.
const idempotencyKey = (job) =>
"quickstart-" + createHash("sha256").update(JSON.stringify(job)).digest("hex").slice(0, 32);
const sleep = (seconds) => new Promise((wake) => setTimeout(wake, seconds * 1000));
/** Poll on a fixed interval until the job reaches a terminal state. */
async function waitFor(jobId) {
const deadline = Date.now() + GIVE_UP_AFTER_SECONDS * 1000;
for (;;) {
const status = await call("GET", `/jobs/${jobId}`);
console.log(` ${status.status} ${status.percentage ?? 0}%`);
if (status.status === "succeeded") return;
if (status.status === "failed" || status.status === "cancelled") {
// error.code is the closed set to branch on. errorCategory is diagnostic only:
// log it, never switch on it.
const ended = status.error ?? {};
throw new ApiError(`job ${status.status}: ${ended.code ?? "unknown"}: ` +
`${ended.message ?? ""} [category ${status.errorCategory}]`);
}
if (Date.now() > deadline) {
throw new ApiError(`job ${jobId} still ${status.status} after ` +
`${GIVE_UP_AFTER_SECONDS}s, giving up.`);
}
await sleep(POLL_SECONDS);
}
}
function report(result) {
const totals = result.summary;
console.log(`${totals.carriersUsed} carrier(s), ${totals.itemsPlaced} placed, ` +
`${totals.itemsUnplaced} unplaced, ${totals.totalWeight} ` +
`${result.units.weight} in total`);
for (const carrier of result.carriers) {
console.log(` ${carrier.externalId} #${carrier.instance}: ` +
`${carrier.kpis.volumeUtilization.toFixed(1)}% of volume, ` +
`${carrier.kpis.itemCount} item(s)`);
for (const placed of carrier.placements) {
console.log(` ${placed.externalId} at (${placed.x}, ${placed.y}, ${placed.z}) ` +
`rz=${placed.orientationRz} itemId=${placed.itemId}`);
}
}
for (const missing of result.unplacedItems) {
console.log(` unplaced: ${missing.amount} of ${missing.description} ` +
`(itemId ${missing.itemId})`);
}
}
try {
const accepted = await call("POST", "/jobs", JOB,
{ "Idempotency-Key": idempotencyKey(JOB) });
console.log(`submitted ${accepted.jobId} (${accepted.status})`);
await waitFor(accepted.jobId);
report(await call("GET", `/jobs/${accepted.jobId}/result`));
} catch (problem) {
if (!(problem instanceof ApiError)) throw problem;
console.error(problem.message);
process.exitCode = 1;
}
6. A mixed palletizing job
Everything above plans a load. To build mixed pallets instead, one field changes: problemType. The two clients below submit the palletizing job from Anatomy of a job and walk the same three steps as the clients in step 5. They differ from them in two places only: the body at the top of the file, and what the result is read for.
Copy either listing below into a file of your own. Both run on a stock installation with no packages added, as in step 5, and both take your key the same three ways in the same order of precedence. Assuming you saved them as mixed-palletizing.py and mixed-palletizing.mjs:
python mixed-palletizing.py ck_live_...
node mixed-palletizing.mjs ck_live_...
Instead of a flat list of placements, each one prints a build sheet: a block per pallet with the mix it carries, its loaded weight, how high the stack ended up, and its placements ordered bottom to top. Three things in there are worth copying into your own code:
- The stack height is derived, not returned. No field carries it. A placement's
zis its lower corner andsizeZits rotated height, so the top of the load is the highestz + sizeZacross a pallet's placements. - Sort before you print. A carrier's
placementscome back in no documented order, so do not read the array order as a build order. Sorting byzis what turns the same data into a build sequence. loadedWeightleaves out the pallet itself. It is the summed weight of that carrier's placements; add thetareWeightyou submitted for the weight someone has to move.
Mixed palletizing has to be included in your plan. If it is not, the submit comes back as 403 with code: "mixed_palletizing_not_entitled", and both clients print a line naming it, because the way out is a change to the plan rather than to the request. It also draws on its own monthly quota, separate from the load-planning one; see Account limits.
#!/usr/bin/env python3
"""Mixed-palletizing client for the Load Optimizer API: build the fewest pallets from mixed cargo.
The same three calls as quickstart.py (submit, poll, read the result). What differs is the job
body, which declares problemType and offers a pallet with no quantity cap, and what the result is
read for: a build sheet per pallet instead of a flat list of placements.
Needs Python 3.9 or newer and nothing installed. Give it your key in whichever way suits you:
python mixed-palletizing.py ck_live_... as the first argument
API_KEY = "ck_live_..." filled in below
LOADOPTIMIZER_API_KEY=ck_live_... in the environment
An argument wins over API_KEY, which wins over the environment.
"""
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE_URL = "https://api.loadoptimizer.ai/v2"
POLL_SECONDS = 2
GIVE_UP_AFTER_SECONDS = 600
# Your key. Leave it empty to take it from the first argument or from the environment. Filling it
# in here is the least typing while you try things out, and also the easiest one to commit by
# accident; the environment is the only one of the three that stays out of both this file and your
# shell history.
API_KEY = ""
# One store's picked order, to be built onto as few EUR pallets as the cargo allows.
JOB = {
"units": {"length": "m", "weight": "kg"},
# problemType is the only field that selects the problem. Without it this is a load-planning
# job, and the same cargo comes back planned into containers.
"problemType": "MixedPalletizing",
"carriers": [{
"externalId": "EUR-PALLET",
"name": "EUR pallet, 1.8 m build height",
# Only "pallet" is accepted on a MixedPalletizing job. Omitting it fills in that same value
# from problemType, so this line documents the intent rather than changing the outcome.
"category": "pallet",
# A carrier's dimensionZ is how high the stack may go, not the thickness of the deck.
"geometry": {"type": "box", "dimensionX": 1.2, "dimensionY": 0.8, "dimensionZ": 1.8},
# No cap: the solver adds pallets until the cargo runs out. That is what makes the answer
# "how many pallets does this order need" instead of "does it fit the ones I named".
"quantity": None,
"tareWeight": 25,
}],
# Three lines rather than one: the mix is the whole point of a mixed pallet, and the per-line
# constraints below are what keep the heavy crates underneath the fragile trays.
"cargo": [
{"externalId": "SKU-4001", "description": "Beverage crate", "amount": 24, "weight": 12.5,
"geometry": {"type": "box", "dimensionX": 0.4, "dimensionY": 0.3, "dimensionZ": 0.25},
"constraints": {"allowStackingOnTop": True, "allowedRotations": ["Z"]},
"groups": ["store-104"]},
{"externalId": "SKU-4002", "description": "Cereal case", "amount": 18, "weight": 4.0,
"geometry": {"type": "box", "dimensionX": 0.6, "dimensionY": 0.4, "dimensionZ": 0.3},
"constraints": {"allowStackingOnTop": True},
"groups": ["store-104"]},
# Nothing may rest on this line and it may not turn: the two rules that decide it ends up
# on top. allowStackingOnTop defaults to false, so the other two lines have to opt in.
{"externalId": "SKU-4003", "description": "Egg tray", "amount": 6, "weight": 2.2,
"geometry": {"type": "box", "dimensionX": 0.3, "dimensionY": 0.3, "dimensionZ": 0.15},
"constraints": {"allowStackingOnTop": False, "allowedRotations": []},
"groups": ["store-104"]},
],
"configuration": {
"objective": "MinimizeCarriers",
"secondaryObjectives": [{"type": "CenterLoadMass"}],
},
"name": "Store 104 mixed pallets",
}
class ApiError(Exception):
"""A refused call or a job that ended badly. The message is ready to log."""
def api_key():
"""The key: first argument, else API_KEY above, else the environment."""
if len(sys.argv) > 1:
return sys.argv[1]
return API_KEY or os.environ.get("LOADOPTIMIZER_API_KEY", "")
def call(method, path, body=None, extra_headers=None):
key = api_key()
if not key:
raise ApiError("No API key. Pass it as the first argument, fill in API_KEY in this file, "
"or set LOADOPTIMIZER_API_KEY.")
request = urllib.request.Request(
BASE_URL + path,
data=json.dumps(body).encode("utf-8") if body is not None else None,
method=method,
headers={"X-Api-Key": key, "Content-Type": "application/json",
**(extra_headers or {})})
try:
with urllib.request.urlopen(request) as answer:
return json.loads(answer.read() or b"null")
except urllib.error.HTTPError as refused:
# HTTPError first: it is a subclass of URLError, so the other order would report every
# answer the API gave as if nothing had answered at all.
raise ApiError(explain(refused)) from refused
except urllib.error.URLError as unreachable:
raise ApiError(f"Could not reach {BASE_URL}: {unreachable.reason}") from unreachable
def explain(refused):
"""Read the problem+json body. A few answers (401, 409) have an empty body by design."""
raw = refused.read().decode("utf-8", "replace").strip()
if not raw:
return f"HTTP {refused.code} with an empty body."
try:
problem = json.loads(raw)
except ValueError:
return f"HTTP {refused.code}: {raw[:400]}"
lines = [f"HTTP {problem.get('status', refused.code)} "
f"{problem.get('code', '')}: {problem.get('title', '')}".rstrip()]
for item in problem.get("errors", []):
lines.append(f" {item.get('pointer', '?')}: {item.get('message', '')}")
# The two refusals this job can meet that a load-planning job never does. Both are answered by
# a change to the plan rather than to the request, which is worth saying at the point of
# failure instead of leaving a bare status code.
if problem.get("code") == "mixed_palletizing_not_entitled":
lines.append(" Mixed palletizing is not included in this plan. Remove problemType to "
"submit the same cargo as a load-planning job, or have the plan upgraded.")
if problem.get("code") == "monthly_quota_exceeded":
lines.append(" Each problem type meters its own monthly quota, so this says nothing "
"about the load-planning one.")
return "\n".join(lines)
def idempotency_key(job):
"""Derive the key from the body: resend the same job and you get the original back,
change the job and the key changes with it. Reusing one key for a different body is
refused with an empty 409, and deriving it is how you never hit that."""
digest = hashlib.sha256(json.dumps(job, sort_keys=True).encode("utf-8")).hexdigest()
return f"mixed-palletizing-{digest[:32]}"
def wait_for(job_id):
"""Poll on a fixed interval until the job reaches a terminal state."""
deadline = time.monotonic() + GIVE_UP_AFTER_SECONDS
while True:
status = call("GET", f"/jobs/{job_id}")
state = status["status"]
print(f" {state} {status.get('percentage') or 0}%")
if state == "succeeded":
return
if state in ("failed", "cancelled"):
# error.code is the closed set to branch on. errorCategory is diagnostic only:
# log it, never switch on it.
ended = status.get("error") or {}
raise ApiError(f"job {state}: {ended.get('code', 'unknown')}: "
f"{ended.get('message', '')} "
f"[category {status.get('errorCategory')}]")
if time.monotonic() > deadline:
raise ApiError(f"job {job_id} still {state} after "
f"{GIVE_UP_AFTER_SECONDS}s, giving up.")
time.sleep(POLL_SECONDS)
def mix(placements):
"""What a pallet is carrying, counted per submitted line: the mix that makes it a mixed pallet.
Counts by externalId for a readable line, but that label is an echo and not unique: two cargo
lines may carry the same one. itemId is the join key, so count by that when you attribute a
placement to a line rather than print it.
"""
counted = {}
for placed in placements:
label = placed.get("externalId") or placed.get("description") or placed["itemId"]
counted[label] = counted.get(label, 0) + 1
return ", ".join(f"{count} x {label}" for label, count in sorted(counted.items()))
def report(result):
"""Read the result as a build sheet: one block per pallet, bottom layer first."""
totals = result["summary"]
length = result["units"]["length"]
weight = result["units"]["weight"]
print(f"{totals['carriersUsed']} pallet(s), {totals['itemsPlaced']} placed, "
f"{totals['itemsUnplaced']} unplaced, {totals['totalWeight']} {weight} in total")
for pallet in result["carriers"]:
kpis = pallet["kpis"]
# No field carries the stack height, so derive it: z is a placement's min corner and sizeZ
# its rotated extent, which makes the top of the tallest column the highest z + sizeZ.
# loadedWeight leaves out the pallet itself; add the tareWeight you submitted for the
# weight someone has to move.
top = max((placed["z"] + placed["sizeZ"] for placed in pallet["placements"]), default=0)
print(f" {pallet['externalId']} #{pallet['instance']} ({pallet['category']}): "
f"{kpis['itemCount']} item(s), {kpis['loadedWeight']} {weight} of cargo, "
f"stacked {top:g} {length} high")
print(f" carrying {mix(pallet['placements'])}")
# Bottom to top is the order someone builds the pallet in. placements comes back in no
# documented order, so sort rather than trusting the array order.
for placed in sorted(pallet["placements"],
key=lambda entry: (entry["z"], entry["y"], entry["x"])):
print(f" z={placed['z']:g} {placed['externalId']} at "
f"({placed['x']:g}, {placed['y']:g}) rz={placed['orientationRz']:g} "
f"itemId={placed['itemId']}")
# With no quantity cap the pallet count is never the limit, so an unplaced line here means the
# cargo did not fit the pallet's own footprint or build height.
for missing in result["unplacedItems"]:
print(f" unplaced: {missing['amount']} of {missing['description']} "
f"(itemId {missing['itemId']})")
def main():
try:
accepted = call("POST", "/jobs", JOB,
{"Idempotency-Key": idempotency_key(JOB)})
print(f"submitted {accepted['jobId']} ({accepted['status']})")
wait_for(accepted["jobId"])
report(call("GET", f"/jobs/{accepted['jobId']}/result"))
except ApiError as problem:
print(problem, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env node
/**
* Mixed-palletizing client for the Load Optimizer API: build the fewest pallets from mixed cargo.
*
* The same three calls as quickstart.mjs (submit, poll, read the result). What differs is the job
* body, which declares problemType and offers a pallet with no quantity cap, and what the result
* is read for: a build sheet per pallet instead of a flat list of placements.
*
* Needs Node 18 or newer and nothing installed. Give it your key in whichever way suits you:
*
* node mixed-palletizing.mjs ck_live_... as the first argument
* const API_KEY = "ck_live_..." filled in below
* LOADOPTIMIZER_API_KEY=ck_live_... in the environment
*
* An argument wins over API_KEY, which wins over the environment.
*/
import { createHash } from "node:crypto";
const BASE_URL = "https://api.loadoptimizer.ai/v2";
const POLL_SECONDS = 2;
const GIVE_UP_AFTER_SECONDS = 600;
// Your key. Leave it empty to take it from the first argument or from the environment. Filling it
// in here is the least typing while you try things out, and also the easiest one to commit by
// accident; the environment is the only one of the three that stays out of both this file and your
// shell history.
const API_KEY = "";
// One store's picked order, to be built onto as few EUR pallets as the cargo allows.
const JOB = {
units: { length: "m", weight: "kg" },
// problemType is the only field that selects the problem. Without it this is a load-planning
// job, and the same cargo comes back planned into containers.
problemType: "MixedPalletizing",
carriers: [{
externalId: "EUR-PALLET",
name: "EUR pallet, 1.8 m build height",
// Only "pallet" is accepted on a MixedPalletizing job. Omitting it fills in that same value
// from problemType, so this line documents the intent rather than changing the outcome.
category: "pallet",
// A carrier's dimensionZ is how high the stack may go, not the thickness of the deck.
geometry: { type: "box", dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.8 },
// No cap: the solver adds pallets until the cargo runs out. That is what makes the answer
// "how many pallets does this order need" instead of "does it fit the ones I named".
quantity: null,
tareWeight: 25,
}],
// Three lines rather than one: the mix is the whole point of a mixed pallet, and the per-line
// constraints below are what keep the heavy crates underneath the fragile trays.
cargo: [
{ externalId: "SKU-4001", description: "Beverage crate", amount: 24, weight: 12.5,
geometry: { type: "box", dimensionX: 0.4, dimensionY: 0.3, dimensionZ: 0.25 },
constraints: { allowStackingOnTop: true, allowedRotations: ["Z"] },
groups: ["store-104"] },
{ externalId: "SKU-4002", description: "Cereal case", amount: 18, weight: 4.0,
geometry: { type: "box", dimensionX: 0.6, dimensionY: 0.4, dimensionZ: 0.3 },
constraints: { allowStackingOnTop: true },
groups: ["store-104"] },
// Nothing may rest on this line and it may not turn: the two rules that decide it ends up on
// top. allowStackingOnTop defaults to false, so the other two lines have to opt in.
{ externalId: "SKU-4003", description: "Egg tray", amount: 6, weight: 2.2,
geometry: { type: "box", dimensionX: 0.3, dimensionY: 0.3, dimensionZ: 0.15 },
constraints: { allowStackingOnTop: false, allowedRotations: [] },
groups: ["store-104"] },
],
configuration: {
objective: "MinimizeCarriers",
secondaryObjectives: [{ type: "CenterLoadMass" }],
},
name: "Store 104 mixed pallets",
};
/** A refused call or a job that ended badly. The message is ready to log. */
class ApiError extends Error {}
/** The key: first argument, else API_KEY above, else the environment. */
const apiKey = () => process.argv[2] || API_KEY || process.env.LOADOPTIMIZER_API_KEY || "";
async function call(method, path, body, extraHeaders = {}) {
const key = apiKey();
if (!key) {
throw new ApiError("No API key. Pass it as the first argument, fill in API_KEY in this file, " +
"or set LOADOPTIMIZER_API_KEY.");
}
let answer;
try {
answer = await fetch(BASE_URL + path, {
method,
headers: { "X-Api-Key": key, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (unreachable) {
// fetch rejects only when nothing answered at all: no route, no port, no such name. The outer
// error is a bare "fetch failed", so the reason sits one or two levels down: in cause, or in
// cause.errors when several addresses were tried, which is the usual case since an IPv6 and an
// IPv4 attempt both fail and get bundled into an AggregateError with no message of its own.
const cause = unreachable.cause;
const reason = cause?.errors?.[0]?.message ?? cause?.message ?? unreachable.message;
throw new ApiError(`Could not reach ${BASE_URL}: ${reason}`);
}
// fetch resolves on a 4xx as happily as on a 2xx, so check this yourself: without it you
// parse an error body as if it were a result.
if (!answer.ok) throw new ApiError(await explain(answer));
const raw = await answer.text();
return raw ? JSON.parse(raw) : null;
}
/** Read the problem+json body. A few answers (401, 409) have an empty body by design. */
async function explain(answer) {
const raw = (await answer.text()).trim();
if (!raw) return `HTTP ${answer.status} with an empty body.`;
let problem;
try {
problem = JSON.parse(raw);
} catch {
return `HTTP ${answer.status}: ${raw.slice(0, 400)}`;
}
const lines = [`HTTP ${problem.status ?? answer.status} ${problem.code ?? ""}: ` +
`${problem.title ?? ""}`.trimEnd()];
for (const item of problem.errors ?? []) {
lines.push(` ${item.pointer ?? "?"}: ${item.message ?? ""}`);
}
// The two refusals this job can meet that a load-planning job never does. Both are answered by
// a change to the plan rather than to the request, which is worth saying at the point of failure
// instead of leaving a bare status code.
if (problem.code === "mixed_palletizing_not_entitled") {
lines.push(" Mixed palletizing is not included in this plan. Remove problemType to submit " +
"the same cargo as a load-planning job, or have the plan upgraded.");
}
if (problem.code === "monthly_quota_exceeded") {
lines.push(" Each problem type meters its own monthly quota, so this says nothing about " +
"the load-planning one.");
}
return lines.join("\n");
}
// Derive the key from the body: resend the same job and you get the original back, change the
// job and the key changes with it. Reusing one key for a different body is refused with an
// empty 409, and deriving it is how you never hit that. JSON.stringify has no key ordering, so
// this client and the Python one produce different keys for the same job. That is fine: a key
// only has to be stable per client.
const idempotencyKey = (job) =>
"mixed-palletizing-" + createHash("sha256").update(JSON.stringify(job)).digest("hex").slice(0, 32);
const sleep = (seconds) => new Promise((wake) => setTimeout(wake, seconds * 1000));
// Six significant digits, then back to a number so the trailing zeros go: Python's %g in one line.
// Worth having because a derived length carries binary-float noise that no reader wants on a build
// sheet, and 0.95 is the honest way to print what came out as 0.9500000000000001.
const trim = (value) => Number(value.toPrecision(6));
/** Poll on a fixed interval until the job reaches a terminal state. */
async function waitFor(jobId) {
const deadline = Date.now() + GIVE_UP_AFTER_SECONDS * 1000;
for (;;) {
const status = await call("GET", `/jobs/${jobId}`);
console.log(` ${status.status} ${status.percentage ?? 0}%`);
if (status.status === "succeeded") return;
if (status.status === "failed" || status.status === "cancelled") {
// error.code is the closed set to branch on. errorCategory is diagnostic only:
// log it, never switch on it.
const ended = status.error ?? {};
throw new ApiError(`job ${status.status}: ${ended.code ?? "unknown"}: ` +
`${ended.message ?? ""} [category ${status.errorCategory}]`);
}
if (Date.now() > deadline) {
throw new ApiError(`job ${jobId} still ${status.status} after ` +
`${GIVE_UP_AFTER_SECONDS}s, giving up.`);
}
await sleep(POLL_SECONDS);
}
}
/**
* What a pallet is carrying, counted per submitted line: the mix that makes it a mixed pallet.
*
* Counts by externalId for a readable line, but that label is an echo and not unique: two cargo
* lines may carry the same one. itemId is the join key, so count by that when you attribute a
* placement to a line rather than print it.
*/
function mix(placements) {
const counted = new Map();
for (const placed of placements) {
const label = placed.externalId || placed.description || placed.itemId;
counted.set(label, (counted.get(label) ?? 0) + 1);
}
// Compared with < rather than localeCompare: this ordering has to be the same everywhere the
// script runs, and localeCompare answers to the machine's locale.
return [...counted.entries()].sort(([one], [other]) => (one < other ? -1 : one > other ? 1 : 0))
.map(([label, count]) => `${count} x ${label}`).join(", ");
}
/** Read the result as a build sheet: one block per pallet, bottom layer first. */
function report(result) {
const totals = result.summary;
const length = result.units.length;
const weight = result.units.weight;
console.log(`${totals.carriersUsed} pallet(s), ${totals.itemsPlaced} placed, ` +
`${totals.itemsUnplaced} unplaced, ${totals.totalWeight} ${weight} in total`);
for (const pallet of result.carriers) {
// No field carries the stack height, so derive it: z is a placement's min corner and sizeZ its
// rotated extent, which makes the top of the tallest column the highest z + sizeZ.
// loadedWeight leaves out the pallet itself; add the tareWeight you submitted for the weight
// someone has to move.
const top = pallet.placements.reduce((highest, placed) =>
Math.max(highest, placed.z + placed.sizeZ), 0);
console.log(` ${pallet.externalId} #${pallet.instance} (${pallet.category}): ` +
`${pallet.kpis.itemCount} item(s), ${pallet.kpis.loadedWeight} ${weight} of ` +
`cargo, stacked ${trim(top)} ${length} high`);
console.log(` carrying ${mix(pallet.placements)}`);
// Bottom to top is the order someone builds the pallet in. placements comes back in no
// documented order, so sort rather than trusting the array order.
const building = [...pallet.placements].sort((one, other) =>
one.z - other.z || one.y - other.y || one.x - other.x);
for (const placed of building) {
console.log(` z=${trim(placed.z)} ${placed.externalId} at ` +
`(${trim(placed.x)}, ${trim(placed.y)}) ` +
`rz=${trim(placed.orientationRz)} itemId=${placed.itemId}`);
}
}
// With no quantity cap the pallet count is never the limit, so an unplaced line here means the
// cargo did not fit the pallet's own footprint or build height.
for (const missing of result.unplacedItems) {
console.log(` unplaced: ${missing.amount} of ${missing.description} ` +
`(itemId ${missing.itemId})`);
}
}
try {
const accepted = await call("POST", "/jobs", JOB,
{ "Idempotency-Key": idempotencyKey(JOB) });
console.log(`submitted ${accepted.jobId} (${accepted.status})`);
await waitFor(accepted.jobId);
report(await call("GET", `/jobs/${accepted.jobId}/result`));
} catch (problem) {
if (!(problem instanceof ApiError)) throw problem;
console.error(problem.message);
process.exitCode = 1;
}