Sign in
Developers

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. x runs along the length, front to back; y along the width, left to right; z along the height, floor upwards.
  • A placement's x/y/z is that corner of the piece, not its centre. Its length/width/height are 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.

FieldTypeNotes
spacesarray The spaces you can load into. At least one.
itemsarray What you want loaded. At least one.
strategyobject Placement preferences. Omit to use the application defaults.
referencestring Your own reference. We do not interpret it; it comes back on the job so you can match the result to your record.

spaces[]

FieldTypeNotes
idstring Your identifier. Optional, but without it you cannot tell which packed space is which — they are matched back by position, not by name.
namestringShown in the result.
typestring container · lorry · semi_trailer · trailer · pallet. Empty means container. An unknown value is rejected rather than silently treated as a container.
lengthnumber Required. Internal length in cm, greater than zero.
widthnumber Required. Internal width in cm.
heightnumber Required. Internal height in cm.
max_weightnumber Payload limit in kg. 0 means unlimited. Only enforced when strategy.respect_weight_limit is on.
availableinteger 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.
costnumber Cost per trip, used when choosing between space types. 0 = undefined.
stack_heightnumber 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[]

FieldTypeNotes
idstring Your identifier; returned on every placed piece.
namestringShown in the result.
shapestring box (default) or cylinder. Any other value is rejected.
lengthnumber Required for both shapes. On a cylinder this is the body length.
widthnumber Required for boxes. Ignored on cylinders — derived from the diameter.
heightnumber Required for boxes. Ignored on cylinders.
diameternumber Required for cylinders. Width and height are both taken from it; sending them separately would let you describe a cylinder that cannot exist.
weightnumber Weight in kg per piece, not for the line.
quantityinteger At least 1. Defaults to 1.
colorstring #RRGGBB. Affects drawing only, never placement.
groupstring 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.
constraintsobject See below. All flags default to false.

items[].constraints

FieldTypeNotes
no_tiltboolean Must stay upright; cannot be laid on its side.
no_rotateboolean Cannot be turned about the vertical axis.
no_stackboolean Nothing may be placed on top of it.
floor_onlyboolean 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.

FieldNotes
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.

FieldTypeNotes
idstring Poll /api/v1/jobs/{id} with this.
statusstring queued · running · succeeded · failed · cancelled. Branch on this, and treat anything you do not recognise as still running rather than as an error.
progressinteger 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.
referencestring Whatever you sent, returned unchanged.
created_atstring UTC timestamp.
finished_atstring UTC timestamp, null until the job ends.
creditsinteger What this request cost. Already deducted at submit time.
resultobject Populated only while status is succeeded. See below.
errorobject Populated only while status is failed: code, message and optional details.

Result

Present on the job only while status is succeeded.

FieldNotes
summary.spaces_usedHow many spaces were opened.
summary.pieces_requestedTotal pieces you asked for.
summary.pieces_packed How many were placed. If it is lower, unpacked says why.
summary.weightTotal 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[]

FieldNotes
item_idThe item id you sent.
nameThe item name you sent.
sequenceLoading 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.
weightWeight 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 requestCredits
1 – 1001
101 – 1 0005
1 001 – 10 00025
10 001 and above100

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" }
} }
CodeStatusWhat to do
unauthorized401Check the key and the header format.
invalid_request400Read details.field; the problem is in your payload.
not_found404No such job for your team.
quota_exceeded402Wait for the next period or raise your plan. Do not retry immediately.
forbidden402The API is not included in your plan — no quota was ever granted. Subscribe before retrying.
rate_limited429Monthly test-key limit reached. Switch to a live key; the counter resets on the 1st (UTC).
idempotency_conflict422Reusing a key with a different body. Use a new key.
request_in_progress409Same key, same body, first request still running. Retry in a moment; you will then get the original response.
internal_error500Our 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.

Download openapi/v1.json

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.