Slotting 1.0.0

Webhooks

The install can tell your systems when something happens at the wall: an order is complete and ready to be packed out, a wave is done, a put was confirmed. It sends each event as a signed HTTP POST to the URLs you configure. Your WMS no longer has to poll GET /api/waves/{waveId} to notice that an order is ready.

Events are written in the same transaction as the change that causes them, so an event is never lost when the change was made, and never sent for a change that failed. They are delivered at least once, in order per layout.

Turn them on#

Variable Default
SLOTTING_WEBHOOK_URLS Comma-separated http:// or https:// URLs (at most 10) that receive every event. No user name or password in the URL: receivers check the signature instead
SLOTTING_WEBHOOK_SECRET Required with URLs: the shared secret for the signatures, at least 16 characters. Generate a random one (openssl rand -hex 32) and keep it in your secret store
SLOTTING_WEBHOOK_EVENTS order.completed,wave.completed The event types to send, comma-separated, or * for all of them. Other events are skipped
SLOTTING_WEBHOOK_TIMEOUT_MS 5000 How long one delivery may take before it counts as failed, 100 to 60000
SLOTTING_WEBHOOK_MAX_ATTEMPTS 20 Attempts per event and URL before it is given up as a dead letter, 1 to 50. With the default, an event is retried for about 8 hours
SLOTTING_WEBHOOK_POLL_MS 1000 How often an instance looks for events written by other instances, 100 to 60000. Events of its own changes go out at once
SLOTTING_OUTBOX_MAX_PER_LAYOUT 100000 The most events a layout keeps waiting for delivery, 1 to 10000000. See Outbox limit

Every URL gets every subscribed event. A setting that can't work (a URL that isn't http(s), a missing or short secret, an unknown event type) stops the install at startup with a message; the secret never appears in the log.

Use the Redis store in production: the events waiting to be delivered (the outbox) live in the store, so the memory store loses them on a restart (Persistence and backups).

Without SLOTTING_WEBHOOK_URLS the install keeps no events at all: nothing would ever deliver them. Events of changes made while webhooks were off are not sent later when you turn them on. (Every instance sharing a store needs the same webhook settings: an instance without them writes no events.)

Events#

Every event has the same envelope; data depends on the type:

json
{
  "id": "PUTWALL-A:WAVE-0815:mvj9mso0-3:12",
  "type": "order.completed",
  "at": "2026-10-22T09:14:03.120Z",
  "layoutId": "PUTWALL-A",
  "waveId": "WAVE-0815",
  "data": {
    "orderId": "ORD-1001",
    "bins": [{ "id": "A-01-01", "wallId": "A" }],
    "short": false,
    "unitsPut": 3,
    "unitsTotal": 3
  }
}
Field
id Unique and stable: <layoutId>:<waveId>:<waveInstance>:<n>, numbered per wave. <waveInstance> tells apart waves (and layouts) that reuse an id, so a wave created again with the id of a deleted one gets new ids. Treat the id as opaque text. A delivery that is repeated has the same id. Deduplicate on it
type One of the types below
at When the change was made (ISO time, UTC)
layoutId, waveId Where it happened
data The details, per type
type When data
put A scan put units into bins scanId, orderId, sku, quantity, puts (every bin of the scan: binId, wallId, sku, quantity), pending (true when the wave confirms puts and this one still waits for its confirmation)
put.confirmed A pending put was confirmed by a bin scan or a light button (Confirmed puts); one event per bin scanId, orderId, puts (the confirmed bin)
put.undone A scan was undone scanId, orderId, puts (what was taken back out)
order.completed An order is complete: its last unit was put (or, with confirmed puts, its last put confirmed), it was completed short, or it had only units that fit no bin orderId, bins (every bin holding its units, nearest first: where to pack it out), short, unitsPut, unitsTotal
order.released An order was released (packed out, its bins are free) orderId, bins (the freed bins)
wave.completed Every order of the wave is complete or released. Sent again if an undo reopened the wave and it completes again stats (as in WaveState)
wave.closed The wave was closed stats

Within one change the events come in the order put, order, wave: the scan of an order's last unit gives put, then order.completed, then (if it was the wave's last order) wave.completed. Taking a reservation from an unstarted order, deleting waves and layouts, and moving walls send no events.

For a WMS, order.completed (pack the order out of these bins) and wave.completed are usually all you need, which is why they are the default. Subscribe to put and put.confirmed only if you track every unit; a busy wall sends thousands.

Delivery#

Each event is a POST to each URL, with the event as the JSON body and these headers:

Header
Content-Type application/json
User-Agent slotting/<version>
X-Slotting-Event The event's type
X-Slotting-Delivery The event's id
X-Slotting-Signature t=<unix seconds>,v1=<hex HMAC-SHA256>: Verify the signature

Outbox limit#

If a layout's events can't be delivered for a long time (an endpoint down for days, with SLOTTING_WEBHOOK_MAX_ATTEMPTS at its highest) they pile up. Each layout keeps at most SLOTTING_OUTBOX_MAX_PER_LAYOUT waiting events (100 000 by default, a few tens of MB in Redis): when a change would go over it, the oldest waiting events of that layout are dropped, down to 90 % of the limit, and the install logs an error and writes one dead letter for them, with dropped (how many) and a reason naming the first and last dropped ids (no event). Read the affected waves to catch up, as after any dead letter.

Dead letters#

When an event still fails after SLOTTING_WEBHOOK_MAX_ATTEMPTS attempts, the install gives it up for that URL: it logs an error, keeps the event as a dead letter, and goes on with the layout's next event. The other URLs are not affected.

bash
curl -s "$SLOTTING_URL/api/webhooks/dead-letters"

The answer lists the dead letters, newest first (the last 100; ?max= up to 1000, the most the install keeps), each with the layoutId, the event (as it would have been delivered), the reason (for example endpoint 1 (https://wms.example.internal): HTTP 500 after 20 attempt(s)) and at, the time it was given up. Two kinds have no event: events dropped over the outbox limit (dropped), and an outbox entry that can't be read (reason corrupt entry, raw its text, for example after someone edited the Redis list by hand): it is set aside so the layout's later events still go out. There is no replay: after an outage longer than the retries, read the current state of the affected waves (GET /api/waves/{waveId}) to catch up, and fix the endpoint before the next events give up too.

Verify the signature#

The API has no authentication, and neither do webhooks, so check every delivery's signature before you trust it:

  1. Read the raw body, the exact bytes received. Don't parse and re-serialize the JSON first: the signature is over the bytes.
  2. Split X-Slotting-Signature at the commas into t (the time of signing, unix seconds) and v1 (the signature).
  3. Compute HMAC-SHA256 with your secret over <t>.<raw body> (the time, a dot, the body) and compare it with v1 in constant time. Accept the delivery if any v1 matches.
  4. Refuse it if t is more than 5 minutes from your clock, so a recorded delivery can't be replayed later. Each attempt is signed again, so a retry carries a fresh t. Keep your clocks in sync (NTP).

Answer 401 when the signature doesn't match: the install retries, and the failure is visible in its log, which helps when the secrets differ. Each example below is a complete receiver; our test suite runs the TypeScript, Java and Python ones against a running install.

TypeScript (node:crypto)#

ts
/**
 * True when `header` (the x-slotting-signature header, `t=<unix seconds>,v1=<hex>`) is the HMAC-SHA256 of
 * "<t>.<raw body>" with your secret, and `t` is at most `toleranceSec` seconds away from now (against replays).
 * `rawBody` must be the exact bytes received: verify before parsing the JSON.
 */
export function verifySignature(secret: string, header: string | undefined, rawBody: Buffer, toleranceSec = 300): boolean {
    let t = '';
    const signatures: string[] = [];
    for (const part of (header ?? '').split(',')) {
        const eq = part.indexOf('=');
        const key = part.slice(0, eq).trim();
        const value = part.slice(eq + 1).trim();
        if (key === 't') {
            t = value;
        }
        else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) {
            signatures.push(value);
        }
    }
    if (!/^\d{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
        return false;
    }
    const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
    return signatures.some(sig => timingSafeEqual(expected, Buffer.from(sig, 'hex')));
}

A receiver with node:http (in Express, take the raw body with express.raw({ type: 'application/json' })):

ts
interface SlottingEvent {
    id: string;
    type: string;
    at: string;
    layoutId: string;
    waveId: string;
    data: Record<string, unknown>;
}

const secret = process.env.SLOTTING_WEBHOOK_SECRET ?? '';
const port = Number(process.env.WEBHOOK_PORT ?? 9101);
// Events can arrive more than once: remember the ids you processed. In production, a unique key in your database.
const processed = new Set<string>();

function handle(event: SlottingEvent): void {
    console.log(`verified ${event.type} ${event.id}`);
}

createServer((req, res) => {
    const chunks: Buffer[] = [];
    let size = 0;
    req.on('data', (chunk: Buffer) => {
        size += chunk.length;
        if (size > 1_000_000) {
            res.writeHead(413).end();
            req.destroy();
            return;
        }
        chunks.push(chunk);
    });
    req.on('end', () => {
        const raw = Buffer.concat(chunks);
        const header = req.headers['x-slotting-signature'];
        if (req.method !== 'POST' || !verifySignature(secret, typeof header === 'string' ? header : undefined, raw)) {
            res.writeHead(401).end();
            return;
        }
        const event = JSON.parse(raw.toString('utf8')) as SlottingEvent;
        if (!processed.has(event.id)) {
            processed.add(event.id);
            handle(event);
        }
        // Answer 2xx quickly (within SLOTTING_WEBHOOK_TIMEOUT_MS), also for a duplicate; do slow work afterwards.
        res.writeHead(204).end();
    });
}).listen(port, '127.0.0.1');

Java 17 (javax.crypto)#

java
/**
 * True when {@code header} (the x-slotting-signature header, {@code t=<unix seconds>,v1=<hex>}) is the HMAC-SHA256 of
 * "<t>.<raw body>" with your secret, and {@code t} is at most {@code toleranceSec} seconds away from now (against replays).
 * {@code rawBody} must be the exact bytes received: verify before parsing the JSON.
 */
public static boolean verifySignature(String secret, String header, byte[] rawBody, long toleranceSec) {
    if (header == null) {
        return false;
    }
    String t = "";
    List<String> signatures = new ArrayList<>();
    for (String part : header.split(",")) {
        int eq = part.indexOf('=');
        if (eq <= 0) {
            continue;
        }
        String key = part.substring(0, eq).trim();
        String value = part.substring(eq + 1).trim();
        if (key.equals("t")) {
            t = value;
        }
        else if (key.equals("v1") && HEX_64.matcher(value).matches()) {
            signatures.add(value);
        }
    }
    if (!DIGITS.matcher(t).matches() || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) > toleranceSec) {
        return false;
    }
    byte[] expected;
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
        expected = mac.doFinal(rawBody);
    }
    catch (NoSuchAlgorithmException | InvalidKeyException e) {
        throw new IllegalStateException("HmacSHA256 is not available", e);
    }
    for (String signature : signatures) {
        // MessageDigest.isEqual compares in constant time.
        if (MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) {
            return true;
        }
    }
    return false;
}

A receiver with the JDK's HTTP server (in Spring, take the body as @RequestBody byte[]):

java
// Events can arrive more than once: remember the ids you processed. In production, a unique key in your database.
private static final Set<String> PROCESSED = ConcurrentHashMap.newKeySet();
private static final Pattern EVENT_ID = Pattern.compile("^\\{\"id\":\"((?:[^\"\\\\]|\\\\.)*)\",\"type\":\"([a-z.]+)\"");

public static void main(String[] args) throws IOException {
    String secret = System.getenv().getOrDefault("SLOTTING_WEBHOOK_SECRET", "");
    int port = Integer.parseInt(System.getenv().getOrDefault("WEBHOOK_PORT", "9102"));
    HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
    server.createContext("/", exchange -> {
        try (exchange) {
            byte[] raw = readBody(exchange, 1_000_000);
            String header = exchange.getRequestHeaders().getFirst("X-Slotting-Signature");
            if (raw == null || !exchange.getRequestMethod().equals("POST") || !verifySignature(secret, header, raw, 300)) {
                exchange.sendResponseHeaders(401, -1);
                return;
            }
            // Parse the body with your JSON library (Jackson, Gson); this example only reads id and type, which come first.
            Matcher m = EVENT_ID.matcher(new String(raw, StandardCharsets.UTF_8));
            if (m.find() && PROCESSED.add(m.group(1))) {
                System.out.println("verified " + m.group(2) + " " + m.group(1));
            }
            // Answer 2xx quickly (within SLOTTING_WEBHOOK_TIMEOUT_MS), also for a duplicate; do slow work afterwards.
            exchange.sendResponseHeaders(204, -1);
        }
    });
    server.start();
}

Python (hmac)#

python
def verify_signature(secret, header, raw_body, tolerance_sec=300):
    """True when header (the x-slotting-signature header, "t=<unix seconds>,v1=<hex>") is the HMAC-SHA256 of
    "<t>.<raw body>" with your secret, and t is at most tolerance_sec seconds away from now (against replays).
    raw_body must be the exact bytes received (bytes, not str): verify before parsing the JSON."""
    t = ""
    signatures = []
    for part in (header or "").split(","):
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if key == "t":
            t = value
        elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            signatures.append(value)
    if not re.fullmatch(r"\d{1,12}", t) or abs(time.time() - int(t)) > tolerance_sec:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)

A receiver with http.server (in Flask, request.get_data(); in Django, request.body; in FastAPI, await request.body()):

python
SECRET = os.environ.get("SLOTTING_WEBHOOK_SECRET", "")
# Events can arrive more than once: remember the ids you processed. In production, a unique key in your database.
processed = set()


def handle(event):
    print(f"verified {event['type']} {event['id']}", flush=True)


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length") or 0)
        if length > 1_000_000:
            self.send_response(413)
            self.end_headers()
            return
        raw = self.rfile.read(length)
        if not verify_signature(SECRET, self.headers.get("X-Slotting-Signature"), raw):
            self.send_response(401)
            self.end_headers()
            return
        event = json.loads(raw)
        if event["id"] not in processed:
            processed.add(event["id"])
            handle(event)
        # Answer 2xx quickly (within SLOTTING_WEBHOOK_TIMEOUT_MS), also for a duplicate; do slow work afterwards.
        self.send_response(204)
        self.end_headers()

    def log_message(self, format, *args):  # keep the output to the events
        pass


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", int(os.environ.get("WEBHOOK_PORT", "9103"))), Receiver).serve_forever()

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.

csharp
/// <summary>
/// True when <paramref name="header"/> (the x-slotting-signature header, <c>t=&lt;unix seconds&gt;,v1=&lt;hex&gt;</c>) is the
/// HMAC-SHA256 of "&lt;t&gt;.&lt;raw body&gt;" with your secret, and t is at most <paramref name="toleranceSec"/> seconds
/// away from now (against replays). <paramref name="rawBody"/> must be the exact bytes received: verify before parsing.
/// </summary>
public static bool VerifySignature(string secret, string? header, byte[] rawBody, long toleranceSec = 300)
{
    var t = "";
    var signatures = new List<string>();
    foreach (var part in (header ?? "").Split(','))
    {
        var eq = part.IndexOf('=');
        if (eq <= 0)
        {
            continue;
        }
        var key = part[..eq].Trim();
        var value = part[(eq + 1)..].Trim();
        if (key == "t")
        {
            t = value;
        }
        else if (key == "v1" && value.Length == 64 && value.All(Uri.IsHexDigit))
        {
            signatures.Add(value);
        }
    }
    if (t.Length is 0 or > 12 || !t.All(char.IsAsciiDigit) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > toleranceSec)
    {
        return false;
    }
    var signed = Encoding.UTF8.GetBytes(t + ".").Concat(rawBody).ToArray();
    var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
    // FixedTimeEquals compares in constant time.
    return signatures.Any(sig => CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(sig)));
}

An ASP.NET Core minimal API endpoint:

csharp
var secret = Environment.GetEnvironmentVariable("SLOTTING_WEBHOOK_SECRET") ?? "";
// Events can arrive more than once: remember the ids you processed. In production, a unique key in your database.
var processed = new ConcurrentDictionary<string, bool>();

app.MapPost("/", async (HttpRequest request) =>
{
    if (request.ContentLength is null or > 1_000_000)
    {
        return Results.StatusCode(413);
    }
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var raw = buffer.ToArray();
    if (!SlottingWebhooks.VerifySignature(secret, request.Headers["X-Slotting-Signature"].ToString(), raw))
    {
        return Results.Unauthorized();
    }
    var slottingEvent = JsonNode.Parse(raw)!;
    var id = (string)slottingEvent["id"]!;
    if (processed.TryAdd(id, true))
    {
        Console.WriteLine($"verified {slottingEvent["type"]} {id}");
    }
    // Answer 2xx quickly (within SLOTTING_WEBHOOK_TIMEOUT_MS), also for a duplicate; do slow work afterwards.
    return Results.NoContent();
});

Changing the secret#

The install signs with one secret. To change it without refusing deliveries, let your receivers accept both the old and the new secret for a while (verify with one, then the other), change SLOTTING_WEBHOOK_SECRET and restart the install, then remove the old secret from the receivers. Deliveries refused in between are retried.