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:
{
"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 |
- Delivered means your endpoint answered
2xxwithinSLOTTING_WEBHOOK_TIMEOUT_MS. Answer quickly, also for an event you already processed, and do slow work after answering. - Anything else is retried: another status (also
3xx: redirects are not followed, and4xx), a timeout, a refused connection. The same event is retried after 1 s, then 2 s, 4 s and so on, doubling up to one hour between attempts. While a layout waits for a retry, the instance delivers the other layouts' events (a few failing layouts don't hold up the rest). - In order per layout. The events of a layout are delivered in the order they happened, and a later event waits until the earlier one is delivered or given up. So while one of your URLs is failing, that layout's events wait for all URLs; other layouts are not held up.
- At least once. If the install stops during a delivery, or loses the answer, the event is sent again later. Your
receiver may see an event twice (with the same
id), and should keep the ids it processed (a unique key in your database) and skip repeats. Before waiting to retry a failing event, the install records the events already delivered, so a restart during a long retry repeats only the failing event and those after it. If the store doesn't answer while the install records delivered events, it doesn't know whether they were recorded: it stops and sends them again later, so a slow or failing store leads to repeats, never to lost events. - Several instances share the work: one instance at a time delivers a layout's events, and another takes over when it
stops. Changes made on one instance are delivered at once; the others notice theirs within
SLOTTING_WEBHOOK_POLL_MS. - Deleted layouts still have their remaining events delivered (while webhooks are configured; without them a deleted layout's events go with it).
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.
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:
- Read the raw body, the exact bytes received. Don't parse and re-serialize the JSON first: the signature is over the bytes.
- Split
X-Slotting-Signatureat the commas intot(the time of signing, unix seconds) andv1(the signature). - Compute HMAC-SHA256 with your secret over
<t>.<raw body>(the time, a dot, the body) and compare it withv1in constant time. Accept the delivery if anyv1matches. - Refuse it if
tis 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 fresht. 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)#
/**
* 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' })):
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)#
/**
* 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[]):
// 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)#
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()):
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.
/// <summary>
/// True when <paramref name="header"/> (the x-slotting-signature header, <c>t=<unix seconds>,v1=<hex></c>) is the
/// HMAC-SHA256 of "<t>.<raw body>" 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:
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.