Slotting 1.0.0

Calling the API

Your backend (WMS, WES or an integration service) calls the install's JSON API over HTTP. This page walks through one wave (create the layout, create the wave, scan, release) with curl and with complete examples in TypeScript, Java, Python and C#. Every field is described in the API reference.

Base URL and network#

Quick start#

The examples use one layout, PUTWALL-A: a wall of 2 rows x 3 columns of bins, 40 x 30 cm and 40 cm deep, at most 15 kg each. Create it once:

bash
curl -s -X POST "$SLOTTING_URL/api/layouts" -H 'Content-Type: application/json' -d '{
  "id": "PUTWALL-A", "name": "Put wall A", "unit": "cm", "weightUnit": "kg",
  "walls": [{ "id": "A", "kind": "fixed", "bins": [
    { "id": "A-01-01", "x": 0,  "y": 30, "width": 40, "height": 30, "depth": 40, "maxWeight": 15 },
    { "id": "A-01-02", "x": 40, "y": 30, "width": 40, "height": 30, "depth": 40, "maxWeight": 15 },
    { "id": "A-01-03", "x": 80, "y": 30, "width": 40, "height": 30, "depth": 40, "maxWeight": 15 },
    { "id": "A-02-01", "x": 0,  "y": 0,  "width": 40, "height": 30, "depth": 40, "maxWeight": 15 },
    { "id": "A-02-02", "x": 40, "y": 0,  "width": 40, "height": 30, "depth": 40, "maxWeight": 15 },
    { "id": "A-02-03", "x": 80, "y": 0,  "width": 40, "height": 30, "depth": 40, "maxWeight": 15 }
  ]}]
}'

The answer is the saved layout (201). Creating it again answers 409 duplicate_id; replace a layout with PUT.

Then a wave with two orders and the dimensions of the items in it (the item master, in the layout's units):

bash
curl -s -X POST "$SLOTTING_URL/api/waves" -H 'Content-Type: application/json' -d '{
  "id": "WAVE-CURL-1", "layoutId": "PUTWALL-A",
  "orders": [
    { "id": "ORD-1001", "lines": [{ "sku": "MUG", "quantity": 2 }, { "sku": "BOOK", "quantity": 1 }] },
    { "id": "ORD-1002", "lines": [{ "sku": "MUG", "quantity": 1 }] }
  ],
  "items": {
    "MUG":  { "length": 12, "width": 9,  "height": 10, "weight": 0.4 },
    "BOOK": { "length": 22, "width": 15, "height": 3,  "weight": 0.6 }
  }
}'

The answer (201) has the wave (its orders and their state) and the plan: for each order its bins, nearest first, and the expected puts, or queued when no bins were free. See How slotting decides.

Each scanned unit is one call. The answer's bin.id is where the unit goes:

bash
curl -s -X POST "$SLOTTING_URL/api/waves/WAVE-CURL-1/scan" -H 'Content-Type: application/json' \
     -d '{ "sku": "MUG", "scanId": "station-1-000001", "operator": "station-1" }'
json
{"scanId":"station-1-000001","waveId":"WAVE-CURL-1","sku":"MUG","quantity":1,"orderId":"ORD-1002","bin":{"id":"A-01-02","wallId":"A"},"puts":[{"binId":"A-01-02","wallId":"A","sku":"MUG","quantity":1}],"order":{"id":"ORD-1002","status":"complete","bins":[{"id":"A-01-02","wallId":"A"}],"unitsTotal":1,"unitsPut":1,"remaining":{}},"orderComplete":true,"waveComplete":false,"orderBins":[{"id":"A-01-02","wallId":"A"}]}

The first MUG went to ORD-1002, which needed only that unit (the default allocation finishes orders first), so the order is complete and orderBins lists its bins. After pack-out, release the order's bins:

bash
curl -s -X POST "$SLOTTING_URL/api/waves/WAVE-CURL-1/release" -H 'Content-Type: application/json' -d '{ "orderId": "ORD-1001" }'

When the wave is done, close it (this frees whatever it still holds) and, if you like, delete it; closed waves older than SLOTTING_WAVE_RETENTION_HOURS are also deleted automatically when the next wave is created on the layout:

bash
curl -s -X POST "$SLOTTING_URL/api/waves/WAVE-CURL-1/close" -H 'Content-Type: application/json' -d '{}'
curl -s -X DELETE "$SLOTTING_URL/api/waves/WAVE-CURL-1"

The flow, from your backend#

  1. Layouts change rarely. Create them once, or import them (Bin setups), and keep their ids in your configuration (per station or per wall group).
  2. Create a wave when your WMS releases one to the put wall: id (unique on the install), layoutId, the orders with their lines, and items with the dimensions and weights of the SKUs, in the layout's units. Send dimensions whenever you have them; why. Optional: wallIds (only these walls) and options (allocation, maxBinsPerOrder, fillFactor, wallChangePenalty, compactness, confirmPuts).
  3. Scans come from the stations: either the station component calls the API itself, or your scan screen calls POST /api/waves/{waveId}/scan (through your backend or directly).
  4. Release each order when it has been packed out (your pack station confirms it), with { "orderId" }, or with { "binId" } when the packer scans a bin label.
  5. Close the wave when it is finished, or when you abandon it.

To show progress in your own UI, read GET /api/waves/{waveId} (the wave with every order's state and the stats) and GET /api/layouts/{layoutId}/bins (which bins are held by which order). To be told instead of asking, configure webhooks: order.completed says which bins to pack out, wave.completed that a wave is done. Each event has an id (today <layoutId>:<waveId>:<waveInstance>:<n>, different for a wave created again with a reused id): treat it as opaque text and deduplicate on it.

Scanning from your own screen#

Confirmed puts#

In a wave with "options": { "confirmPuts": true }, a put counts only once the operator confirms it by scanning the bin's label (or pressing its light button, see Put-to-light). The rules are in How slotting decides. The scan answers as usual, with "pending": true:

bash
curl -s -X POST "$SLOTTING_URL/api/waves/WAVE-CURL-2/scan" -H 'Content-Type: application/json' \
     -d '{ "sku": "MUG", "scanId": "station-1-000101", "operator": "station-1" }'
json
{"scanId":"station-1-000101","waveId":"WAVE-CURL-2","sku":"MUG","quantity":1,"orderId":"ORD-2001","bin":{"id":"A-01-01","wallId":"A"},"puts":[{"binId":"A-01-01","wallId":"A","sku":"MUG","quantity":1}],"order":{"id":"ORD-2001","status":"in_progress","bins":[{"id":"A-01-01","wallId":"A"}],"unitsTotal":1,"unitsPut":1,"remaining":{}},"orderComplete":false,"waveComplete":false,"pending":true}

Show the bin and ask for the bin scan. When the operator scans a bin label, send it with the scan's scanId:

bash
curl -s -X POST "$SLOTTING_URL/api/waves/WAVE-CURL-2/confirm" -H 'Content-Type: application/json' \
     -d '{ "binId": "A-01-01", "scanId": "station-1-000101" }'
json
{"scanId":"station-1-000101","waveId":"WAVE-CURL-2","orderId":"ORD-2001","bin":{"id":"A-01-01","wallId":"A"},"order":{"id":"ORD-2001","status":"complete","bins":[{"id":"A-01-01","wallId":"A"}],"unitsTotal":1,"unitsPut":1,"remaining":{}},"orderComplete":true,"waveComplete":true,"orderBins":[{"id":"A-01-01","wallId":"A"}]}

A light gateway reports button presses with POST /api/lights/press: a press confirms the bin's pending put, or, when the bin's order is complete, releases the order (Button presses). A press repeating one of a moment ago (SLOTTING_PRESS_RELEASE_GUARD_MS, default 3 s) answers 200 { "action": "ignored" } and changes nothing:

bash
curl -s -X POST "$SLOTTING_URL/api/lights/press" -H 'Content-Type: application/json' \
     -d '{ "layoutId": "PUTWALL-A", "address": "A-01-01" }'

Errors, retries and timeouts#

Errors are JSON: { "error": "<message>", "code": "<code>" }, plus issues for invalid input and details where useful. The full list is in the API reference.

Status When What to do
400 invalid_request The body doesn't match the schema; issues[].path points at the field Fix the request (a bug in your mapping)
402 license_required, license_expired, product_not_licensed The install has no valid license; checked before the body is read Don't retry; tell the install's operator (License)
403 origin_not_allowed, 421 host_not_allowed A browser page on another origin tried a change, or the Host isn't allowed (Network and security) Configure SLOTTING_CORS_ORIGINS / SLOTTING_ALLOWED_HOSTS, or call from your backend
404 unknown_layout, unknown_wave, unknown_order, unknown_wall, unknown_scan The id doesn't exist
409 duplicate_id, layout_in_use, wave_active, wave_closed, sku_fully_slotted, order_not_complete, order_released, no_bin_available The request conflicts with the current state Show the message; for scans, put the unit aside
409 wrong_bin, not_pending A confirmation for the wrong bin, or for a bin with nothing waiting (Confirmed puts) Show the message; ask for the right bin
409 limit_reached A cap of the install was reached (a demo install with limits) Show the message; close or delete old waves
409 demo_protected A change to the demo layout DEMO of a showcase install (limits) Create your own layout
422 invalid_layout, sku_not_in_wave, item_too_large, invalid_import The content can't be used Show the message (issues lists layout problems)
502 ai_failed, 503 ai_not_configured Photo import only See Bin setups
503 store_busy, store_unavailable The layout is locked by other requests for too long, or Redis can't be reached; with Retry-After Retry after the indicated seconds

TypeScript#

Node 18 or newer with the built-in fetch, no dependencies. slotting-client.ts is a small client with the response types the examples use. It throws SlottingError (with status, code and the parsed body) for every non-2xx answer, after retrying 503s.

ts
// A small client for the slotting API. Node 18+ (built-in fetch), no dependencies. Copy it into your backend.
// The types cover what the examples read; the full shapes are in the API reference and in slotting-components.d.ts.

export interface BinRef {
    id: string;
    wallId: string;
}

export interface Put {
    binId: string;
    wallId: string;
    sku: string;
    quantity: number;
}

export interface OrderState {
    id: string;
    status: 'queued' | 'planned' | 'in_progress' | 'complete' | 'released';
    bins: BinRef[];
    unitsTotal: number;
    unitsPut: number;
    remaining: Record<string, number>;
    short?: boolean;
    unplaceable?: Record<string, number>;
}

export interface WaveState {
    id: string;
    layoutId: string;
    status: 'active' | 'complete' | 'closed';
    createdAt: string;
    orders: OrderState[];
    stats: { orders: number; ordersComplete: number; ordersQueued: number; unitsTotal: number; unitsPut: number; binsUsed: number };
}

export interface PlannedOrder {
    orderId: string;
    status: 'planned' | 'queued';
    bins: BinRef[];
    puts: Put[];
    unplaceable: Record<string, number>;
}

export interface CreateWaveResponse {
    wave: WaveState;
    plan: { orders: PlannedOrder[]; stats: { orders: number; planned: number; queued: number; binsUsed: number; unplaceableUnits: number } };
}

export interface ScanResult {
    scanId: string;
    waveId: string;
    sku: string;
    quantity: number;
    orderId: string;
    /** Where to put the units: the first (often only) bin of `puts`. */
    bin: BinRef;
    puts: Put[];
    order: OrderState;
    orderComplete: boolean;
    /** When `orderComplete`: every bin of the order, nearest first. */
    orderBins?: BinRef[];
    waveComplete: boolean;
    /** True when the wave confirms puts (`options.confirmPuts`): the put counts once its bin is confirmed. */
    pending?: boolean;
}

/** The answer of POST /api/waves/{waveId}/confirm. */
export interface ConfirmResult {
    scanId: string;
    waveId: string;
    orderId: string;
    bin: BinRef;
    order: OrderState;
    orderComplete: boolean;
    orderBins?: BinRef[];
    waveComplete: boolean;
}

export interface ReleaseResult {
    orderIds: string[];
    bins: BinRef[];
}

/** The answer of POST /api/lights/press: a pending put was confirmed, or a complete order was released. */
export type PressResult =
    | { action: 'confirmed'; result: ConfirmResult }
    | ({ action: 'released' } & ReleaseResult);

/** Any answer other than 2xx. `code` is the API's error code (`no_bin_available`, `license_expired`, ...). */
export class SlottingError extends Error {
    constructor(readonly status: number, readonly code: string, message: string, readonly body: unknown) {
        super(message);
        this.name = 'SlottingError';
    }
}

export class SlottingClient {
    constructor(private readonly baseUrl: string, private readonly timeoutMs = 30_000) {}

    createLayout(layout: unknown): Promise<unknown> {
        return this.request('POST', '/api/layouts', layout);
    }

    createWave(wave: unknown): Promise<CreateWaveResponse> {
        return this.request('POST', '/api/waves', wave);
    }

    /** Pass a scanId of your own: repeating it (for example after a timeout) returns the first answer and puts nothing twice. */
    scan(waveId: string, scan: { sku: string; quantity?: number; scanId?: string; orderId?: string; operator?: string }): Promise<ScanResult> {
        return this.request('POST', `/api/waves/${encodeURIComponent(waveId)}/scan`, scan);
    }

    /** Confirms a pending put: the bin the operator scanned after putting the unit, and the scan it belongs to. */
    confirm(waveId: string, confirm: { binId: string; scanId?: string }): Promise<ConfirmResult> {
        return this.request('POST', `/api/waves/${encodeURIComponent(waveId)}/confirm`, confirm);
    }

    /** A button press on a bin's light (what a light gateway sends): confirms the bin's pending put, or releases its complete order. */
    pressLight(layoutId: string, address: string): Promise<PressResult> {
        return this.request('POST', '/api/lights/press', { layoutId, address });
    }

    release(waveId: string, target: { orderId: string } | { binId: string }): Promise<ReleaseResult> {
        return this.request('POST', `/api/waves/${encodeURIComponent(waveId)}/release`, target);
    }

    closeWave(waveId: string): Promise<WaveState> {
        return this.request('POST', `/api/waves/${encodeURIComponent(waveId)}/close`, {});
    }

    deleteWave(waveId: string): Promise<unknown> {
        return this.request('DELETE', `/api/waves/${encodeURIComponent(waveId)}`);
    }

    /** Sends one request; retries a 503 (store busy or unreachable) up to 3 times, honouring Retry-After. */
    private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
        for (let attempt = 0; ; attempt++) {
            const res = await fetch(this.baseUrl + path, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : JSON.stringify(body),
                signal: AbortSignal.timeout(this.timeoutMs),
            });
            const text = await res.text();
            const json: unknown = text ? JSON.parse(text) : undefined;
            if (res.ok) {
                return json as T;
            }
            if (res.status === 503 && attempt < 3) {
                const seconds = Number(res.headers.get('retry-after'));
                await new Promise(resolve => setTimeout(resolve, (Number.isFinite(seconds) && seconds > 0 ? seconds : attempt + 1) * 1000));
                continue;
            }
            const err = (json ?? {}) as { error?: string; code?: string };
            throw new SlottingError(res.status, err.code ?? 'http_error', `${method} ${path}: ${res.status} ${err.error ?? text}`, json);
        }
    }
}

The four steps:

ts
// One put wall "A": 2 rows x 3 columns of 40 x 30 x 40 cm bins. x / y: bottom-left of each opening on the wall face.
const bins = [];
for (const [row, y] of [[1, 30], [2, 0]]) {
    for (const [col, x] of [[1, 0], [2, 40], [3, 80]]) {
        bins.push({ id: `A-0${row}-0${col}`, x, y, width: 40, height: 30, depth: 40, maxWeight: 15 });
    }
}
try {
    await client.createLayout({ id: 'PUTWALL-A', name: 'Put wall A', unit: 'cm', weightUnit: 'kg', walls: [{ id: 'A', kind: 'fixed', bins }] });
}
catch (err) {
    // The layout is created once; afterwards every wave uses it.
    if (!(err instanceof SlottingError && err.code === 'duplicate_id')) {
        throw err;
    }
}
ts
const { wave, plan } = await client.createWave({
    id: 'WAVE-TS-1',
    layoutId: 'PUTWALL-A',
    orders: [
        { id: 'ORD-1001', lines: [{ sku: 'MUG', quantity: 2 }, { sku: 'BOOK', quantity: 1 }] },
        { id: 'ORD-1002', lines: [{ sku: 'MUG', quantity: 1 }] },
    ],
    // Item master: dimensions in the layout's units (cm, kg).
    items: { MUG: { length: 12, width: 9, height: 10, weight: 0.4 }, BOOK: { length: 22, width: 15, height: 3, weight: 0.6 } },
});
for (const o of plan.orders) {
    console.log(`${o.orderId}: ${o.status} ${o.bins.map(b => b.id).join(', ')}`);
}
ts
// One call per scanned unit. The answer says which bin to put it in; when the order is complete, all its bins.
for (const sku of ['MUG', 'MUG', 'MUG', 'BOOK']) {
    const r = await client.scan(wave.id, { sku, scanId: `${wave.id}-${crypto.randomUUID()}`, operator: 'station-1' });
    console.log(`${sku} -> bin ${r.bin.id} (order ${r.orderId})${r.orderComplete ? `, order complete: ${r.orderBins!.map(b => b.id).join(', ')}` : ''}`);
}
ts
// The packer has emptied the order's bins: release them for the next orders.
for (const orderId of ['ORD-1001', 'ORD-1002']) {
    const released = await client.release(wave.id, { orderId });
    console.log(`released ${released.orderIds.join(', ')}: ${released.bins.map(b => b.id).join(', ')}`);
}

Confirmed puts and a light button press:

ts
// A wave with confirmed puts: each put counts only once the operator scans the bin label (or presses its light button).
const confirmWave = await client.createWave({
    id: 'WAVE-TS-2', layoutId: 'PUTWALL-A', options: { confirmPuts: true },
    orders: [{ id: 'ORD-2001', lines: [{ sku: 'MUG', quantity: 1 }] }],
    items: { MUG: { length: 12, width: 9, height: 10, weight: 0.4 } },
});
const put = await client.scan(confirmWave.wave.id, { sku: 'MUG', scanId: `WAVE-TS-2-${crypto.randomUUID()}` });
console.log(`MUG -> bin ${put.bin.id} (order ${put.orderId}), pending: ${put.pending === true}`);
// The operator put the mug in and scanned the bin label: send the scanned bin with the scan's id.
const confirmed = await client.confirm(confirmWave.wave.id, { binId: put.bin.id, scanId: put.scanId });
console.log(`confirmed ${confirmed.bin.id}${confirmed.orderComplete ? `, order complete: ${confirmed.orderBins!.map(b => b.id).join(', ')}` : ''}`);
ts
// A light gateway reports a button press on the bin's light (its address: the bin's lightAddress, else the bin id).
// The order in that bin is complete, so the press means "packed out": the order is released.
const pressed = await client.pressLight('PUTWALL-A', put.bin.id);
console.log(pressed.action === 'released' ? `pressed ${put.bin.id}: released ${pressed.orderIds.join(', ')}` : `pressed ${put.bin.id}: confirmed`);

It prints:

text
ORD-1001: planned A-01-01
ORD-1002: planned A-01-02
MUG -> bin A-01-02 (order ORD-1002), order complete: A-01-02
MUG -> bin A-01-01 (order ORD-1001)
MUG -> bin A-01-01 (order ORD-1001)
BOOK -> bin A-01-01 (order ORD-1001), order complete: A-01-01
released ORD-1001: A-01-01
released ORD-1002: A-01-02
MUG -> bin A-01-01 (order ORD-2001), pending: true
confirmed A-01-01, order complete: A-01-01
pressed A-01-01: released ORD-2001

Java#

Java 17 with only the JDK: java.net.http.HttpClient, request bodies as text blocks, and a small JSON reader for the answers. In your own service, use your JSON library (Jackson, Gson) for both; the HTTP part stays the same. SlottingClient.java throws SlottingClient.SlottingException (with status() and code()) for every non-2xx answer, after retrying 503s.

java
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * A small client for the slotting API. Java 17, JDK only (java.net.http): request bodies are JSON strings you build, and
 * answers are parsed by the small reader below into Map / List / String / Double / Boolean. In your own service, use your
 * JSON library (Jackson, Gson) instead of {@link Json}; the HTTP part stays the same.
 */
public final class SlottingClient {
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
    private final String baseUrl;

    public SlottingClient(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    /** Any answer other than 2xx. {@code code()} is the API's error code ({@code no_bin_available}, {@code license_expired}, ...). */
    public static final class SlottingException extends RuntimeException {
        private static final long serialVersionUID = 1L;

        private final int status;
        private final String code;

        SlottingException(int status, String code, String message) {
            super(message);
            this.status = status;
            this.code = code;
        }

        public int status() {
            return status;
        }

        public String code() {
            return code;
        }
    }

    public Map<String, Object> post(String path, String json) {
        return send("POST", path, json);
    }

    public Map<String, Object> delete(String path) {
        return send("DELETE", path, null);
    }

    /** A path segment (wave id, order id) with URL escaping. */
    public static String seg(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
    }

    /** Sends one request; retries a 503 (store busy or unreachable) up to 3 times, honouring Retry-After. */
    @SuppressWarnings("unchecked")
    private Map<String, Object> send(String method, String path, String json) {
        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(30));
        if (json != null) {
            builder.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(json));
        }
        else {
            builder.method(method, HttpRequest.BodyPublishers.noBody());
        }
        HttpRequest request = builder.build();
        try {
            for (int attempt = 0; ; attempt++) {
                HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
                Object body = response.body().isEmpty() ? new LinkedHashMap<String, Object>() : Json.parse(response.body());
                Map<String, Object> map = body instanceof Map ? (Map<String, Object>) body : Map.of("value", body);
                int status = response.statusCode();
                if (status >= 200 && status < 300) {
                    return map;
                }
                if (status == 503 && attempt < 3) {
                    long seconds = response.headers().firstValue("Retry-After").filter(v -> v.matches("\\d+")).map(Long::parseLong).orElse(attempt + 1L);
                    Thread.sleep(seconds * 1000);
                    continue;
                }
                String code = map.get("code") instanceof String c ? c : "http_error";
                throw new SlottingException(status, code, method + " " + path + ": " + status + " " + map.getOrDefault("error", response.body()));
            }
        }
        catch (IOException e) {
            throw new SlottingException(0, "offline", method + " " + path + ": " + e.getMessage());
        }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new SlottingException(0, "interrupted", method + " " + path + " was interrupted");
        }
    }

    /** A small JSON reader: objects become LinkedHashMap, arrays ArrayList, numbers Double, plus String, Boolean and null. */
    static final class Json {
        private final String text;
        private int pos;

        private Json(String text) {
            this.text = text;
        }

        static Object parse(String text) {
            Json json = new Json(text);
            try {
                Object value = json.value(0);
                json.skipWhitespace();
                if (json.pos != text.length()) {
                    throw json.error();
                }
                return value;
            }
            catch (IndexOutOfBoundsException | NumberFormatException e) {
                throw json.error();
            }
        }

        private IllegalArgumentException error() {
            return new IllegalArgumentException("Invalid JSON at offset " + pos);
        }

        private Object value(int depth) {
            if (depth > 64) {
                throw error();
            }
            skipWhitespace();
            char c = text.charAt(pos);
            if (c == '{') {
                return object(depth);
            }
            else if (c == '[') {
                return array(depth);
            }
            else if (c == '"') {
                return string();
            }
            else if (text.startsWith("true", pos)) {
                pos += 4;
                return Boolean.TRUE;
            }
            else if (text.startsWith("false", pos)) {
                pos += 5;
                return Boolean.FALSE;
            }
            else if (text.startsWith("null", pos)) {
                pos += 4;
                return null;
            }
            else {
                return number();
            }
        }

        private Map<String, Object> object(int depth) {
            Map<String, Object> map = new LinkedHashMap<>();
            pos++;
            skipWhitespace();
            if (text.charAt(pos) == '}') {
                pos++;
                return map;
            }
            while (true) {
                skipWhitespace();
                String key = string();
                skipWhitespace();
                expect(':');
                map.put(key, value(depth + 1));
                skipWhitespace();
                if (text.charAt(pos) == ',') {
                    pos++;
                }
                else {
                    expect('}');
                    return map;
                }
            }
        }

        private List<Object> array(int depth) {
            List<Object> list = new ArrayList<>();
            pos++;
            skipWhitespace();
            if (text.charAt(pos) == ']') {
                pos++;
                return list;
            }
            while (true) {
                list.add(value(depth + 1));
                skipWhitespace();
                if (text.charAt(pos) == ',') {
                    pos++;
                }
                else {
                    expect(']');
                    return list;
                }
            }
        }

        private String string() {
            expect('"');
            StringBuilder sb = new StringBuilder();
            while (true) {
                char c = text.charAt(pos++);
                if (c == '"') {
                    return sb.toString();
                }
                else if (c == '\\') {
                    char e = text.charAt(pos++);
                    switch (e) {
                        case '"', '\\', '/' -> sb.append(e);
                        case 'b' -> sb.append('\b');
                        case 'f' -> sb.append('\f');
                        case 'n' -> sb.append('\n');
                        case 'r' -> sb.append('\r');
                        case 't' -> sb.append('\t');
                        case 'u' -> {
                            sb.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16));
                            pos += 4;
                        }
                        default -> throw error();
                    }
                }
                else {
                    sb.append(c);
                }
            }
        }

        private Double number() {
            int start = pos;
            while (pos < text.length() && "+-0123456789.eE".indexOf(text.charAt(pos)) >= 0) {
                pos++;
            }
            if (start == pos) {
                throw error();
            }
            return Double.valueOf(text.substring(start, pos));
        }

        private void expect(char c) {
            if (text.charAt(pos) != c) {
                throw error();
            }
            pos++;
        }

        private void skipWhitespace() {
            while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) {
                pos++;
            }
        }
    }
}

The four steps (from SlottingExample.java, in main):

java
// One put wall "A": 2 rows x 3 columns of 40 x 30 x 40 cm bins. x / y: bottom-left of each opening on the wall face.
StringBuilder bins = new StringBuilder();
for (int row = 1; row <= 2; row++) {
    for (int col = 1; col <= 3; col++) {
        bins.append(bins.isEmpty() ? "" : ",\n").append("""
                {"id": "A-0%d-0%d", "x": %d, "y": %d, "width": 40, "height": 30, "depth": 40, "maxWeight": 15}"""
                .formatted(row, col, (col - 1) * 40, row == 1 ? 30 : 0));
    }
}
try {
    client.post("/api/layouts", """
            {"id": "PUTWALL-A", "name": "Put wall A", "unit": "cm", "weightUnit": "kg",
             "walls": [{"id": "A", "kind": "fixed", "bins": [%s]}]}
            """.formatted(bins));
}
catch (SlottingClient.SlottingException e) {
    // The layout is created once; afterwards every wave uses it.
    if (!e.code().equals("duplicate_id")) {
        throw e;
    }
}
java
Map<String, Object> created = client.post("/api/waves", """
        {
          "id": "WAVE-JAVA-1",
          "layoutId": "PUTWALL-A",
          "orders": [
            {"id": "ORD-1001", "lines": [{"sku": "MUG", "quantity": 2}, {"sku": "BOOK", "quantity": 1}]},
            {"id": "ORD-1002", "lines": [{"sku": "MUG", "quantity": 1}]}
          ],
          "items": {
            "MUG": {"length": 12, "width": 9, "height": 10, "weight": 0.4},
            "BOOK": {"length": 22, "width": 15, "height": 3, "weight": 0.6}
          }
        }
        """);
String waveId = (String) ((Map<String, Object>) created.get("wave")).get("id");
for (Map<String, Object> o : (List<Map<String, Object>>) ((Map<String, Object>) created.get("plan")).get("orders")) {
    System.out.println(o.get("orderId") + ": " + o.get("status") + " " + binIds(o.get("bins")));
}
java
// One call per scanned unit. The answer says which bin to put it in; when the order is complete, all its bins.
for (String sku : List.of("MUG", "MUG", "MUG", "BOOK")) {
    Map<String, Object> r = client.post("/api/waves/" + SlottingClient.seg(waveId) + "/scan", """
            {"sku": "%s", "scanId": "%s-%s", "operator": "station-1"}
            """.formatted(sku, waveId, UUID.randomUUID()));
    String bin = (String) ((Map<String, Object>) r.get("bin")).get("id");
    String done = Boolean.TRUE.equals(r.get("orderComplete")) ? ", order complete: " + binIds(r.get("orderBins")) : "";
    System.out.println(sku + " -> bin " + bin + " (order " + r.get("orderId") + ")" + done);
}
java
// The packer has emptied the order's bins: release them for the next orders.
for (String orderId : List.of("ORD-1001", "ORD-1002")) {
    Map<String, Object> released = client.post("/api/waves/" + SlottingClient.seg(waveId) + "/release", """
            {"orderId": "%s"}
            """.formatted(orderId));
    System.out.println("released " + String.join(", ", (List<String>) released.get("orderIds")) + ": " + binIds(released.get("bins")));
}

Confirmed puts and a light button press:

java
// A wave with confirmed puts: each put counts only once the operator scans the bin label (or presses its light button).
client.post("/api/waves", """
        {"id": "WAVE-JAVA-2", "layoutId": "PUTWALL-A", "options": {"confirmPuts": true},
         "orders": [{"id": "ORD-2001", "lines": [{"sku": "MUG", "quantity": 1}]}],
         "items": {"MUG": {"length": 12, "width": 9, "height": 10, "weight": 0.4}}}
        """);
Map<String, Object> put = client.post("/api/waves/WAVE-JAVA-2/scan", """
        {"sku": "MUG", "scanId": "WAVE-JAVA-2-%s"}
        """.formatted(UUID.randomUUID()));
String putBin = (String) ((Map<String, Object>) put.get("bin")).get("id");
System.out.println("MUG -> bin " + putBin + " (order " + put.get("orderId") + "), pending: " + Boolean.TRUE.equals(put.get("pending")));
// The operator put the mug in and scanned the bin label: send the scanned bin with the scan's id.
Map<String, Object> confirmed = client.post("/api/waves/WAVE-JAVA-2/confirm", """
        {"binId": "%s", "scanId": "%s"}
        """.formatted(putBin, put.get("scanId")));
String complete = Boolean.TRUE.equals(confirmed.get("orderComplete")) ? ", order complete: " + binIds(confirmed.get("orderBins")) : "";
System.out.println("confirmed " + ((Map<String, Object>) confirmed.get("bin")).get("id") + complete);
java
// A light gateway reports a button press on the bin's light (its address: the bin's lightAddress, else the bin id).
// The order in that bin is complete, so the press means "packed out": the order is released.
Map<String, Object> pressed = client.post("/api/lights/press", """
        {"layoutId": "PUTWALL-A", "address": "%s"}
        """.formatted(putBin));
System.out.println("released".equals(pressed.get("action"))
        ? "pressed " + putBin + ": released " + String.join(", ", (List<String>) pressed.get("orderIds"))
        : "pressed " + putBin + ": confirmed");

Compile and run with javac SlottingClient.java SlottingExample.java && java SlottingExample. It prints the same as the TypeScript example. The example puts SKUs and ids into JSON with formatted(): fine for these constants, but use a JSON library for data from your systems, so quotes and backslashes are escaped.

Python#

Python 3.8 or newer, standard library only. call() retries 503s and raises SlottingError (with status and code) for other errors.

python
# Python 3.8+, standard library only. Run: python3 slotting_example.py (SLOTTING_URL overrides the URL of the install).
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid

BASE_URL = os.environ.get("SLOTTING_URL", "http://127.0.0.1:10083")


class SlottingError(Exception):
    def __init__(self, status, code, message):
        super().__init__(message)
        self.status = status
        self.code = code


def call(method, path, body=None):
    """One API call. Retries a 503 (store busy or unreachable) up to 3 times; raises SlottingError for other errors."""
    data = None if body is None else json.dumps(body).encode()
    headers = {} if body is None else {"Content-Type": "application/json"}
    for attempt in range(4):
        request = urllib.request.Request(BASE_URL + path, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                text = response.read().decode()
                return json.loads(text) if text else None
        except urllib.error.HTTPError as e:
            text = e.read().decode()
            if e.code == 503 and attempt < 3:
                retry_after = e.headers.get("Retry-After", "")
                time.sleep(int(retry_after) if retry_after.isdigit() else attempt + 1)
                continue
            try:
                err = json.loads(text)
            except ValueError:
                err = {}
            raise SlottingError(e.code, err.get("code", "http_error"), f"{method} {path}: {e.code} {err.get('error', text)}") from None


def wave_path(wave_id, action=""):
    return "/api/waves/" + urllib.parse.quote(wave_id, safe="") + action


# One put wall "A": 2 rows x 3 columns of 40 x 30 x 40 cm bins. x / y: bottom-left of each opening on the wall face.
bins = [
    {"id": f"A-0{row}-0{col}", "x": x, "y": y, "width": 40, "height": 30, "depth": 40, "maxWeight": 15}
    for row, y in ((1, 30), (2, 0))
    for col, x in ((1, 0), (2, 40), (3, 80))
]
try:
    call("POST", "/api/layouts", {
        "id": "PUTWALL-A", "name": "Put wall A", "unit": "cm", "weightUnit": "kg",
        "walls": [{"id": "A", "kind": "fixed", "bins": bins}],
    })
except SlottingError as e:
    if e.code != "duplicate_id":  # the layout is created once; afterwards every wave uses it
        raise

created = call("POST", "/api/waves", {
    "id": "WAVE-PY-1",
    "layoutId": "PUTWALL-A",
    "orders": [
        {"id": "ORD-1001", "lines": [{"sku": "MUG", "quantity": 2}, {"sku": "BOOK", "quantity": 1}]},
        {"id": "ORD-1002", "lines": [{"sku": "MUG", "quantity": 1}]},
    ],
    # Item master: dimensions in the layout's units (cm, kg).
    "items": {
        "MUG": {"length": 12, "width": 9, "height": 10, "weight": 0.4},
        "BOOK": {"length": 22, "width": 15, "height": 3, "weight": 0.6},
    },
})
wave_id = created["wave"]["id"]
for o in created["plan"]["orders"]:
    print(f"{o['orderId']}: {o['status']} {', '.join(b['id'] for b in o['bins'])}")

# One call per scanned unit. The answer says which bin to put it in; when the order is complete, all its bins.
for sku in ["MUG", "MUG", "MUG", "BOOK"]:
    r = call("POST", wave_path(wave_id, "/scan"), {"sku": sku, "scanId": f"{wave_id}-{uuid.uuid4()}", "operator": "station-1"})
    done = f", order complete: {', '.join(b['id'] for b in r['orderBins'])}" if r["orderComplete"] else ""
    print(f"{sku} -> bin {r['bin']['id']} (order {r['orderId']}){done}")

# The packer has emptied the order's bins: release them for the next orders.
for order_id in ["ORD-1001", "ORD-1002"]:
    released = call("POST", wave_path(wave_id, "/release"), {"orderId": order_id})
    print(f"released {', '.join(released['orderIds'])}: {', '.join(b['id'] for b in released['bins'])}")

call("POST", wave_path(wave_id, "/close"), {})
call("DELETE", wave_path(wave_id))

# A wave with confirmed puts: each put counts only once the operator scans the bin label (or presses its light button).
call("POST", "/api/waves", {
    "id": "WAVE-PY-2", "layoutId": "PUTWALL-A", "options": {"confirmPuts": True},
    "orders": [{"id": "ORD-2001", "lines": [{"sku": "MUG", "quantity": 1}]}],
    "items": {"MUG": {"length": 12, "width": 9, "height": 10, "weight": 0.4}},
})
put = call("POST", wave_path("WAVE-PY-2", "/scan"), {"sku": "MUG", "scanId": f"WAVE-PY-2-{uuid.uuid4()}"})
print(f"MUG -> bin {put['bin']['id']} (order {put['orderId']}), pending: {str(put.get('pending', False)).lower()}")
# The operator put the mug in and scanned the bin label: send the scanned bin with the scan's id.
confirmed = call("POST", wave_path("WAVE-PY-2", "/confirm"), {"binId": put["bin"]["id"], "scanId": put["scanId"]})
done = f", order complete: {', '.join(b['id'] for b in confirmed['orderBins'])}" if confirmed["orderComplete"] else ""
print(f"confirmed {confirmed['bin']['id']}{done}")

# The packer empties the bin first: a press within 3 s of completion is ignored (SLOTTING_PRESS_RELEASE_GUARD_MS).
time.sleep(3.2)

# A light gateway reports a button press on the bin's light (its address: the bin's lightAddress, else the bin id).
# The order in that bin is complete, so the press means "packed out": the order is released.
pressed = call("POST", "/api/lights/press", {"layoutId": "PUTWALL-A", "address": put["bin"]["id"]})
if pressed["action"] == "released":
    print(f"pressed {put['bin']['id']}: released {', '.join(pressed['orderIds'])}")
else:
    print(f"pressed {put['bin']['id']}: confirmed")

call("POST", wave_path("WAVE-PY-2", "/close"), {})
call("DELETE", wave_path("WAVE-PY-2"))

It prints the same as the TypeScript example.

C# (.NET 8)#

Not compiled in our test suite. There is no .NET SDK in our test environment, so this code was written and reviewed carefully but has not been compiled or run. Please report anything that doesn't build.

A console app (dotnet new console), HttpClient and System.Text.Json.Nodes, no extra packages. In ASP.NET Core, register SlottingClient as a typed client with AddHttpClient<SlottingClient>() and the base URL from configuration.

csharp
// .NET 8 console app (dotnet new console, implicit usings on), no extra packages. Run: dotnet run (SLOTTING_URL overrides the URL).
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;

var client = new SlottingClient(new HttpClient
{
    BaseAddress = new Uri(Environment.GetEnvironmentVariable("SLOTTING_URL") ?? "http://127.0.0.1:10083"),
    Timeout = TimeSpan.FromSeconds(30),
});

// One put wall "A": 2 rows x 3 columns of 40 x 30 x 40 cm bins. x / y: bottom-left of each opening on the wall face.
var bins = new JsonArray();
foreach (var (row, y) in new[] { (1, 30), (2, 0) })
{
    foreach (var (col, x) in new[] { (1, 0), (2, 40), (3, 80) })
    {
        bins.Add(new JsonObject { ["id"] = $"A-0{row}-0{col}", ["x"] = x, ["y"] = y, ["width"] = 40, ["height"] = 30, ["depth"] = 40, ["maxWeight"] = 15 });
    }
}
try
{
    await client.PostAsync("/api/layouts", new JsonObject
    {
        ["id"] = "PUTWALL-A", ["name"] = "Put wall A", ["unit"] = "cm", ["weightUnit"] = "kg",
        ["walls"] = new JsonArray(new JsonObject { ["id"] = "A", ["kind"] = "fixed", ["bins"] = bins }),
    });
}
catch (SlottingException e) when (e.Code == "duplicate_id")
{
    // The layout is created once; afterwards every wave uses it.
}

var created = await client.PostAsync("/api/waves", JsonNode.Parse("""
    {
      "id": "WAVE-CS-1",
      "layoutId": "PUTWALL-A",
      "orders": [
        {"id": "ORD-1001", "lines": [{"sku": "MUG", "quantity": 2}, {"sku": "BOOK", "quantity": 1}]},
        {"id": "ORD-1002", "lines": [{"sku": "MUG", "quantity": 1}]}
      ],
      "items": {
        "MUG": {"length": 12, "width": 9, "height": 10, "weight": 0.4},
        "BOOK": {"length": 22, "width": 15, "height": 3, "weight": 0.6}
      }
    }
    """)!);
var waveId = (string)created["wave"]!["id"]!;
foreach (var o in created["plan"]!["orders"]!.AsArray())
{
    Console.WriteLine($"{o!["orderId"]}: {o["status"]} {BinIds(o["bins"])}");
}

// One call per scanned unit. The answer says which bin to put it in; when the order is complete, all its bins.
foreach (var sku in new[] { "MUG", "MUG", "MUG", "BOOK" })
{
    var r = await client.PostAsync($"/api/waves/{Uri.EscapeDataString(waveId)}/scan",
        new JsonObject { ["sku"] = sku, ["scanId"] = $"{waveId}-{Guid.NewGuid()}", ["operator"] = "station-1" });
    var done = (bool)r["orderComplete"]! ? $", order complete: {BinIds(r["orderBins"])}" : "";
    Console.WriteLine($"{sku} -> bin {r["bin"]!["id"]} (order {r["orderId"]}){done}");
}

// The packer has emptied the order's bins: release them for the next orders.
foreach (var orderId in new[] { "ORD-1001", "ORD-1002" })
{
    var released = await client.PostAsync($"/api/waves/{Uri.EscapeDataString(waveId)}/release", new JsonObject { ["orderId"] = orderId });
    Console.WriteLine($"released {string.Join(", ", released["orderIds"]!.AsArray())}: {BinIds(released["bins"])}");
}

await client.PostAsync($"/api/waves/{Uri.EscapeDataString(waveId)}/close", new JsonObject());
await client.DeleteAsync($"/api/waves/{Uri.EscapeDataString(waveId)}");

// A wave with confirmed puts: each put counts only once the operator scans the bin label (or presses its light button).
await client.PostAsync("/api/waves", JsonNode.Parse("""
    {"id": "WAVE-CS-2", "layoutId": "PUTWALL-A", "options": {"confirmPuts": true},
     "orders": [{"id": "ORD-2001", "lines": [{"sku": "MUG", "quantity": 1}]}],
     "items": {"MUG": {"length": 12, "width": 9, "height": 10, "weight": 0.4}}}
    """)!);
var put = await client.PostAsync("/api/waves/WAVE-CS-2/scan", new JsonObject { ["sku"] = "MUG", ["scanId"] = $"WAVE-CS-2-{Guid.NewGuid()}" });
var putBin = (string)put["bin"]!["id"]!;
Console.WriteLine($"MUG -> bin {putBin} (order {put["orderId"]}), pending: {((bool?)put["pending"] ?? false).ToString().ToLowerInvariant()}");
// The operator put the mug in and scanned the bin label: send the scanned bin with the scan's id.
var confirmed = await client.PostAsync("/api/waves/WAVE-CS-2/confirm", new JsonObject { ["binId"] = putBin, ["scanId"] = (string)put["scanId"]! });
var complete = (bool)confirmed["orderComplete"]! ? $", order complete: {BinIds(confirmed["orderBins"])}" : "";
Console.WriteLine($"confirmed {confirmed["bin"]!["id"]}{complete}");

// The packer empties the bin first: a press within 3 s of completion is ignored (SLOTTING_PRESS_RELEASE_GUARD_MS).
await Task.Delay(3200);

// A light gateway reports a button press on the bin's light (its address: the bin's lightAddress, else the bin id).
// The order in that bin is complete, so the press means "packed out": the order is released.
var pressed = await client.PostAsync("/api/lights/press", new JsonObject { ["layoutId"] = "PUTWALL-A", ["address"] = putBin });
Console.WriteLine((string?)pressed["action"] == "released"
    ? $"pressed {putBin}: released {string.Join(", ", pressed["orderIds"]!.AsArray())}"
    : $"pressed {putBin}: confirmed");

await client.PostAsync("/api/waves/WAVE-CS-2/close", new JsonObject());
await client.DeleteAsync("/api/waves/WAVE-CS-2");

static string BinIds(JsonNode? bins) => string.Join(", ", bins!.AsArray().Select(b => (string)b!["id"]!));

/// <summary>Any answer other than 2xx. <c>Code</c> is the API's error code (<c>no_bin_available</c>, <c>license_expired</c>, ...).</summary>
public sealed class SlottingException(int status, string code, string message) : Exception(message)
{
    public int Status { get; } = status;
    public string Code { get; } = code;
}

/// <summary>A small client for the slotting API. In ASP.NET Core, register it as a typed client (AddHttpClient&lt;SlottingClient&gt;).</summary>
public sealed class SlottingClient(HttpClient http)
{
    public Task<JsonNode> PostAsync(string path, JsonNode body) => SendAsync(HttpMethod.Post, path, body);

    public Task<JsonNode> DeleteAsync(string path) => SendAsync(HttpMethod.Delete, path, null);

    /// <summary>Sends one request; retries a 503 (store busy or unreachable) up to 3 times, honouring Retry-After.</summary>
    private async Task<JsonNode> SendAsync(HttpMethod method, string path, JsonNode? body)
    {
        for (var attempt = 0; ; attempt++)
        {
            using var request = new HttpRequestMessage(method, path);
            if (body is not null)
            {
                request.Content = JsonContent.Create(body);
            }
            using var response = await http.SendAsync(request);
            var text = await response.Content.ReadAsStringAsync();
            var json = text.Length > 0 ? JsonNode.Parse(text) : new JsonObject();
            if (response.IsSuccessStatusCode)
            {
                return json!;
            }
            if (response.StatusCode == HttpStatusCode.ServiceUnavailable && attempt < 3)
            {
                await Task.Delay(response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(attempt + 1));
                continue;
            }
            var code = (string?)json?["code"] ?? "http_error";
            throw new SlottingException((int)response.StatusCode, code, $"{method} {path}: {(int)response.StatusCode} {(string?)json?["error"] ?? text}");
        }
    }
}