API reference
Send spaces and items, get a placement plan. This page is in English because its content is code: field names, schemas and error codes do not translate.
Quick start
Create a test key on the API keys page. A test key runs the real engine and returns a real plan, and does not consume credits; it is capped at 25 calls per month. Live calls require a plan that includes the API.
curl -X POST https://loader4d.com/api/v1/pack \
-H "Authorization: Bearer l4d_test_..." \
-H "Content-Type: application/json" \
-d '{
"reference": "SO-2026-4417",
"spaces": [
{ "id": "sp1", "name": "40HC", "type": "container",
"length": 1200, "width": 235, "height": 269, "max_weight": 26000 }
],
"items": [
{ "id": "A1", "name": "Carton", "length": 120, "width": 100,
"height": 40, "weight": 8, "quantity": 24 }
]
}'
You get a job id back straight away:
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/ba7189d3b1824da3806ca3ee973b2294
{ "id": "ba7189d3...", "status": "queued", "progress": 0,
"reference": "SO-2026-4417", "credits": 1 }
Then poll until it finishes — typically a few seconds:
curl https://loader4d.com/api/v1/jobs/ba7189d3... \
-H "Authorization: Bearer l4d_test_..."
Units and coordinates
Getting these wrong is the most expensive mistake you can make against this API, so they are stated plainly.
- Lengths are centimetres, weights are kilograms. There is no per-request unit setting; convert on your side. A request in inches loads a lorry ten times too long.
- The origin is the front-left-bottom corner of the space.
xruns along the length, front to back;yalong the width, left to right;zalong the height, floor upwards. - A placement's
x/y/zis that corner of the piece, not its centre. Itslength/width/heightare the placed dimensions and may differ from what you sent if the piece was rotated.
Request body
Three top-level keys. spaces and items are required and
must not be empty; strategy is optional.
| Field | Type | Notes |
|---|---|---|
| spaces | array | The spaces you can load into. At least one. |
| items | array | What you want loaded. At least one. |
| strategy | object | Placement preferences. Omit to use the application defaults. |
| reference | string | Your own reference. We do not interpret it; it comes back on the job so you can match the result to your record. |
spaces[]
| Field | Type | Notes |
|---|---|---|
| id | string | Your identifier. Optional, but without it you cannot tell which packed space is which — they are matched back by position, not by name. |
| name | string | Shown in the result. |
| type | string | container · lorry · semi_trailer ·
trailer · pallet. Empty means container. An unknown value
is rejected rather than silently treated as a container. |
| length | number | Required. Internal length in cm, greater than zero. |
| width | number | Required. Internal width in cm. |
| height | number | Required. Internal height in cm. |
| max_weight | number | Payload limit in kg. 0 means unlimited. Only enforced when
strategy.respect_weight_limit is on. |
| available | integer | How many of this space you actually have. 0 means unlimited. The
engine will not exceed it, so a customer with two lorries never sees a third in
the plan. |
| cost | number | Cost per trip, used when choosing between space types. 0 = undefined. |
| stack_height | number | Pallets only: how high goods may rise above the pallet, in cm. Leaving it at 0 on a pallet squeezes the load into the pallet's own thickness and almost everything comes back unpacked. |
items[]
| Field | Type | Notes |
|---|---|---|
| id | string | Your identifier; returned on every placed piece. |
| name | string | Shown in the result. |
| shape | string | box (default) or cylinder. Any other value is rejected. |
| length | number | Required for both shapes. On a cylinder this is the body length. |
| width | number | Required for boxes. Ignored on cylinders — derived from the diameter. |
| height | number | Required for boxes. Ignored on cylinders. |
| diameter | number | Required for cylinders. Width and height are both taken from it; sending them separately would let you describe a cylinder that cannot exist. |
| weight | number | Weight in kg per piece, not for the line. |
| quantity | integer | At least 1. Defaults to 1. |
| color | string | #RRGGBB. Affects drawing only, never placement. |
| group | string | Items sharing a group are placed together in their own zone. Use it when the
unloading order matters. Only has an effect with
strategy.group_items. |
| constraints | object | See below. All flags default to false. |
items[].constraints
| Field | Type | Notes |
|---|---|---|
| no_tilt | boolean | Must stay upright; cannot be laid on its side. |
| no_rotate | boolean | Cannot be turned about the vertical axis. |
| no_stack | boolean | Nothing may be placed on top of it. |
| floor_only | boolean | May only sit on the floor. |
strategy
Every field is a nullable boolean, and the distinction matters:
omitting a field leaves the default alone; sending false switches it off.
Without that difference you would have to restate every preference in order to change one.
| Field | Notes |
|---|---|
| group_items | Place each group in its own zone of the space. |
| respect_weight_limit | Honour max_weight. |
| multi_space | Allow more than one space to be opened. Turned on automatically when you send more than one space, so you do not have to send the flag as well. |
| nest_cylinders | Let cylinders nest inside one another. |
| balance_load | Even the weight out across the axes. |
| keep_order | Preserve the order you sent, for LIFO-style unloading. |
The job object
What POST /pack and GET /jobs/{id} both return.
| Field | Type | Notes |
|---|---|---|
| id | string | Poll /api/v1/jobs/{id} with this. |
| status | string | queued · running · succeeded ·
failed · cancelled. Branch on this, and treat anything
you do not recognise as still running rather than as an error. |
| progress | integer | 0–100. A rough indicator for a progress bar — do not use it to decide that the
job has finished; status is the only authority on that. |
| reference | string | Whatever you sent, returned unchanged. |
| created_at | string | UTC timestamp. |
| finished_at | string | UTC timestamp, null until the job ends. |
| credits | integer | What this request cost. Already deducted at submit time. |
| result | object | Populated only while status is succeeded. See below. |
| error | object | Populated only while status is failed:
code, message and optional details. |
Result
Present on the job only while status is succeeded.
| Field | Notes |
|---|---|
| summary.spaces_used | How many spaces were opened. |
| summary.pieces_requested | Total pieces you asked for. |
| summary.pieces_packed | How many were placed. If it is lower, unpacked says why. |
| summary.weight | Total loaded weight in kg. |
| summary.volume_utilization | A ratio between 0 and 1, not a percentage. |
| spaces[].space_id | The id you gave, matched back by position. null if you gave none. |
| spaces[].placements[] | One entry per piece — see below. |
| spaces[].axle_loads[] | name, position (cm from the front), load and
max_load in kg. Empty unless the space type defines axles. |
| unpacked[] | item_id, requested, packed and a
machine-readable reason such as too_large or
weight_limit. There is deliberately no human-readable sentence: that
text would depend on a language, and an API response should not. |
placements[]
| Field | Notes |
|---|---|
| item_id | The item id you sent. |
| name | The item name you sent. |
| sequence | Loading order of this piece, starting at 1. |
| x, y, z | The piece's front-left-bottom corner, in cm from the space's own front-left-bottom corner. Not the centre. |
| length, width, height | The dimensions as placed. If the piece was rotated these will not match what you sent, and drawing them from your original values will put overlapping boxes on the screen. |
| weight | Weight of this single piece, in kg. |
| pallet_id | Set when the piece was loaded onto a pallet rather than straight into the space. |
Why a job instead of a direct answer
An optimisation takes seconds and grows with the shipment. If the response were synchronous, a dropped connection would lose the result and you would pay for the same computation twice. With a job id you come back and collect it.
To be clear about what this does not give you: a job that fails is not resumed from where it stopped. The search is a single computation and no intermediate state is kept. What you gain is that the result is not lost, not that the work continues.
Authentication
Send your key as a bearer token:
Authorization: Bearer l4d_live_...
- Keys beginning
l4d_test_do not consume credits. Use them while you build. - Keys are never accepted in a query string — query strings end up in server logs, proxy records and browser history.
- A missing key and an invalid key both return
401. Telling them apart would give feedback to someone guessing.
Code examples
The same loop in three languages: submit, poll with a backoff, collect. Each one handles the four things a real integration needs — an idempotency key, a growing poll interval, an overall timeout, and branching on the error code rather than the message.
C#
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var http = new HttpClient { BaseAddress = new Uri("https://loader4d.com") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("LOADER4D_KEY"));
var body = JsonSerializer.Serialize(new
{
reference = "SO-2026-4417",
spaces = new[] { new { id = "sp1", type = "container",
length = 1200.0, width = 235.0, height = 269.0, max_weight = 26000.0 } },
items = new[] { new { id = "A1", length = 120.0, width = 100.0, height = 40.0,
weight = 8.0, quantity = 24 } }
});
var post = new HttpRequestMessage(HttpMethod.Post, "/api/v1/pack")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
// Ag hatasinda yeniden denerken AYNI anahtar gonderilmeli; her denemede
// yeni bir Guid uretmek idempotensi tamamen etkisiz kilar.
post.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString("N"));
var accepted = await http.SendAsync(post);
var job = JsonDocument.Parse(await accepted.Content.ReadAsStringAsync()).RootElement;
if (!accepted.IsSuccessStatusCode)
{
var code = job.GetProperty("error").GetProperty("code").GetString();
throw new InvalidOperationException($"pack rejected: {code}");
}
var id = job.GetProperty("id").GetString();
var wait = TimeSpan.FromMilliseconds(500);
var deadline = DateTime.UtcNow.AddMinutes(5);
while (true)
{
if (DateTime.UtcNow > deadline) throw new TimeoutException($"job {id} still running");
await Task.Delay(wait);
wait = TimeSpan.FromMilliseconds(Math.Min(wait.TotalMilliseconds * 1.5, 5000));
var res = await http.GetStringAsync($"/api/v1/jobs/{id}");
var cur = JsonDocument.Parse(res).RootElement;
var status = cur.GetProperty("status").GetString();
if (status == "succeeded")
{
var s = cur.GetProperty("result").GetProperty("summary");
Console.WriteLine($"{s.GetProperty("pieces_packed").GetInt32()} pieces, " +
$"{s.GetProperty("volume_utilization").GetDouble():P1} full");
break;
}
if (status is "failed" or "cancelled")
throw new InvalidOperationException(cur.GetProperty("error").GetProperty("code").GetString());
}
Python
import os, time, uuid, requests
BASE = "https://loader4d.com"
S = requests.Session()
S.headers["Authorization"] = "Bearer " + os.environ["LOADER4D_KEY"]
payload = {
"reference": "SO-2026-4417",
"spaces": [{"id": "sp1", "type": "container",
"length": 1200, "width": 235, "height": 269, "max_weight": 26000}],
"items": [{"id": "A1", "length": 120, "width": 100, "height": 40,
"weight": 8, "quantity": 24}],
}
r = S.post(BASE + "/api/v1/pack", json=payload,
headers={"Idempotency-Key": uuid.uuid4().hex})
if r.status_code != 202:
# Kod SABIT, mesaj degil. Mesaj metnine gore dallanan bir istemci, biz
# bir cumleyi duzeltince sessizce bozulur.
raise RuntimeError("rejected: " + r.json()["error"]["code"])
job_id = r.json()["id"]
wait, deadline = 0.5, time.time() + 300
while True:
if time.time() > deadline:
raise TimeoutError("job %s still running" % job_id)
time.sleep(wait)
wait = min(wait * 1.5, 5.0)
job = S.get("%s/api/v1/jobs/%s" % (BASE, job_id)).json()
if job["status"] == "succeeded":
s = job["result"]["summary"]
print("%d pieces, %.1f%% full" % (s["pieces_packed"], s["volume_utilization"] * 100))
for u in job["result"]["unpacked"]:
print(" left out:", u["item_id"], u["reason"])
break
if job["status"] in ("failed", "cancelled"):
raise RuntimeError(job["error"]["code"])
JavaScript (Node 18+)
const BASE = "https://loader4d.com";
const auth = { Authorization: `Bearer ${process.env.LOADER4D_KEY}` };
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function pack(payload) {
const res = await fetch(`${BASE}/api/v1/pack`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify(payload),
});
const job = await res.json();
if (res.status !== 202) throw new Error(`rejected: ${job.error.code}`);
let wait = 500;
const deadline = Date.now() + 5 * 60_000;
while (true) {
if (Date.now() > deadline) throw new Error(`job ${job.id} still running`);
await sleep(wait);
wait = Math.min(wait * 1.5, 5000);
const cur = await (await fetch(`${BASE}/api/v1/jobs/${job.id}`, { headers: auth })).json();
if (cur.status === "succeeded") return cur.result;
if (cur.status === "failed" || cur.status === "cancelled") throw new Error(cur.error.code);
}
}
const result = await pack({
reference: "SO-2026-4417",
spaces: [{ id: "sp1", type: "container",
length: 1200, width: 235, height: 269, max_weight: 26000 }],
items: [{ id: "A1", length: 120, width: 100, height: 40, weight: 8, quantity: 24 }],
});
console.log(result.summary.pieces_packed, "pieces");
A note on retrying the submit: if the connection drops before you see the 202 you do not know whether the job was created. Resending with the same idempotency key is safe and is the whole point of the header — generating a fresh key on the retry is the one mistake that makes it useless.
Credits
Billing is banded, not per piece. The engine spends the same search budget on a small shipment as on a large one, so per-piece pricing would penalise small calls and make your bill impossible to predict.
| Total pieces in the request | Credits |
|---|---|
| 1 – 100 | 1 |
| 101 – 1 000 | 5 |
| 1 001 – 10 000 | 25 |
| 10 001 and above | 100 |
Credits are deducted when the job is accepted, not when it finishes —
otherwise a hundred concurrent calls would all pass the quota check before any of them counted.
Check your balance with GET /api/v1/usage.
Retrying safely
Send an Idempotency-Key header with a value you generate per logical
request. A retry with the same key and the same body returns the original job rather than
creating a second one.
Idempotency-Key: 7f3c1a9e4b6d4c2f8e0a1b2c3d4e5f60
The same key with a different body is rejected with 422.
Silently returning the earlier answer would leave you waiting for work that was never
queued.
Errors
Every failure has the same shape, whatever the status code, so you only need one parsing path.
{ "error": {
"code": "invalid_request",
"message": "height must be greater than zero",
"details": { "field": "items[0].height" }
} }
| Code | Status | What to do |
|---|---|---|
| unauthorized | 401 | Check the key and the header format. |
| invalid_request | 400 | Read details.field; the problem is in your payload. |
| not_found | 404 | No such job for your team. |
| quota_exceeded | 402 | Wait for the next period or raise your plan. Do not retry immediately. |
| forbidden | 402 | The API is not included in your plan — no quota was ever granted. Subscribe before retrying. |
| rate_limited | 429 | Monthly test-key limit reached. Switch to a live key; the counter resets on the 1st (UTC). |
| idempotency_conflict | 422 | Reusing a key with a different body. Use a new key. |
| request_in_progress | 409 | Same key, same body, first request still running. Retry in a moment; you will then get the original response. |
| internal_error | 500 | Our fault. Retrying once is reasonable. |
message is written for developers, is English and does not vary by
language. Branch on code, never on the message text.
Limits
- At most 50 spaces per request.
- At most 2 000 item lines per request.
- At most 20 000 pieces in total across all lines.
- At most 10 active keys per team.
These exist so that one call cannot occupy the service for minutes. If your workload needs more, split it or get in touch.
Machine-readable specification
The full OpenAPI 3.0 document. Most languages can generate a typed client from it, which is the difference between an integration that takes hours and one that takes days.
The specification is checked against the code by the test suite: a field that exists in one and not the other fails the build. A document that disagrees with the API is worse than no document, because you would trust it.