# Shelfbot API Specification

**Doc:** SB-SPEC-API
**Version:** 3.3
**Date:** 10 September 2026

***

## 1. Introduction

This document defines the wire protocol between a Warehouse Management System (WMS) and a Shelfbot Automated Storage & Retrieval System (ASRS) installation. It is intended for WMS integrators, systems architects, and integration test engineers responsible for connecting a customer WMS to a Shelfbot deployment.

The API is a bidirectional HTTP webhook protocol. The WMS owns business logic and customer-facing state (catalogue, orders, fulfilment records, authoritative stock counts). Shelfbot owns physical tote state, robot operation, scan validation, and the human-machine interface (HMI) at each station. The two systems exchange authoritative events to keep their views consistent.

This specification forms part of the Shelfbot integration contract. A WMS is compatible when it implements the WMS to Shelfbot endpoints correctly and acknowledges the event stream; which event types it processes is its own choice. Two integration profiles are documented in the Shelfbot IT Integration document: Shelfbot Simple (handle `order.completed` and use the read endpoints) and Shelfbot Full (process the complete event stream).

## 2. Purpose & Scope

### 2.1 Purpose

The purpose of this specification is to define the wire-level contract — transport, authentication, payload schemas, and event semantics — that a WMS must implement to integrate with a Shelfbot deployment. It aims to give integrators everything they need to build, test, and certify a compliant integration without needing to read Shelfbot source code.

### 2.2 Scope

This document covers:

* HTTP transport requirements and TLS policy
* Authentication, signing, and replay protection
* Idempotency and deduplication
* Batching rules and partial-failure semantics
* Response envelope and error-code vocabulary
* Retry policy and failure classification
* Endpoint definitions for WMS → Shelfbot (SKU sync, order lifecycle, carton feed, batch status)
* Endpoint definitions for reading current state (SKU list and summary, SKU detail, order state, bin contents, shipper lookup, batch lookup)
* Endpoint definitions for Shelfbot → WMS (order progress and shipper events, inventory events, exceptions)
* Batch and serial tracking semantics

This document does not cover:

* Shelfbot robot mechanical, electrical, or firmware specifications
* Shelfbot HMI user experience or operator workflows
* Shelfbot-internal concepts not exposed in the contract (totes, compartments, aisles, stations, scanner hardware)
* Warehouse racking, floor slab, fire engineering, or sprinkler design
* Customer-facing WMS UX
* Internal Shelfbot database schemas or module layout

For these topics, refer to the Shelfbot Racking Specification, the Shelfbot Electrical Specification, and the Shelfbot User Manual.

## 3. API Overview

Integration is a pair of HTTP webhook endpoints facing each other — the WMS hosts one set, Shelfbot hosts the other.
All payloads are JSON and all requests are signed. Write operations use POST; Shelfbot also hosts a set of GET endpoints for reading current state (see *Reading current state*).

### Role split

* **WMS is the source of truth** for: the SKU catalogue (what exists, what it's called, what it looks like, what hazards it carries); orders (what customers have requested, current fulfilment state); authoritative inventory counts at the SKU / batch / serial level; accounting / commercial workflows.
* **Shelfbot is the source of truth** for: physical tote state, robot and station availability, pick-in-progress state, scan validity. Bins are identified by `bin_id` in event payloads and MAY be read via the endpoints in *Reading current state*; compartment structure within a bin remains internal to Shelfbot and is never exposed in WMS-facing payloads. Shelfbot also decides which shipper each order is picked into and when a shipper is full; the WMS is told through shipper events and reads and never assigns shippers (see *Shippers*).

Neither side trusts the other's view of its own domain. On receipt of an event, each side validates against its own state and refuses invariant violations.

### Scanning

All scanning is performed by Shelfbot-attached scanners at Shelfbot stations. Shelfbot owns scan validation end-to-end and is the source of all pick, stock-in, and adjustment events emitted to the WMS. The WMS does not operate scanners against Shelfbot stock and does not originate physical-state-changing events.

### Shippers

A shipper is the container an order is picked into on the put-to-light trolley: a shipping carton, or a reusable order tote. To the API a shipper is nothing more than the label code the customer printed and stuck on it, bound to an order for a while. Shelfbot decides which order goes into which shipper as the runner inducts it, and when a shipper is full; an order may therefore be picked into one shipper or into several, one after another. The WMS is told what happened through the `shipper.opened` and `shipper.closed` events (*POST /v1/order/event*), the shipper list on the order read (*GET /v1/orders/{id}*) and a lookup by label (*GET /v1/shippers/{shipper_id}*). Nothing about shippers is sent to Shelfbot: the WMS never assigns, sizes or counts them, and a shipper's kind and size are the customer's own knowledge, carried by its label, not part of this contract.

Labels may be reused. A label stays bound to its order until the WMS closes that order with `state: "fulfilled"` or `"cancelled"` (*POST /v1/order/update*); until then the lookup resolves the label to the order it holds, whether or not the shipper is still on the trolley. The API identity of a shipper is therefore the order, the label and `shipper_seq`, a counter Shelfbot assigns within the order (1 for its first shipper, 2 for the next), which keeps two uses of one label against the same order apart.

### Directionality convention

An endpoint is named from the receiver's perspective. `/v1/sku/update` is hosted by Shelfbot and called by the WMS; `/v1/order/event` is hosted by the WMS and called by Shelfbot. *Endpoints — WMS → Shelfbot* defines the Shelfbot-hosted endpoints; *Endpoints — Shelfbot → WMS* defines the WMS-hosted endpoints.
*Reading current state* defines additional Shelfbot-hosted GET endpoints, also called by the WMS.

## 4. Protocol

This section defines the protocol-level contract (transport, auth, idempotency, batching, envelope, and retry) that applies to every endpoint in this specification. One asymmetry is called out where it applies: requests into Shelfbot return the full response envelope with per-item results, while event deliveries to the WMS are acknowledged by any HTTP 2xx (see *Acknowledgement* under the Shelfbot to WMS endpoints).

### 4.1 Transport

| Requirement       | Value                                                                                                 |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| Scheme            | `https://` only — plain HTTP MUST be rejected                                                         |
| TLS               | 1.2 minimum, 1.3 preferred                                                                            |
| Cert verification | Required. Per-deployment `insecure_skip_verify` is permitted for lab/dev only and MUST default to off |
| Method            | `POST` for all write endpoints; the read endpoints in *Reading current state* use `GET`               |
| Content-Type      | `application/json; charset=utf-8` — receivers MUST return `415` otherwise                             |
| Max body size     | 1 MB — receivers MUST return `413 { error.code: "BODY_TOO_LARGE" }` above that                        |
| Request timeout   | 30s — sender aborts and treats as network failure (→ retry)                                           |
| URL prefix        | `/v1/` for all endpoints in this version                                                              |

### 4.2 Authentication

Every request is signed with HMAC-SHA256 over the raw request body, plus a timestamp to prevent replay.
For body-less GET requests the raw body is the empty string (see *Authentication and envelope* under *Reading current state*).

#### 4.2.1 Required headers

| Header                  | Example             | Notes                                                                                             |
| ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------- |
| `X-Shelfbot-Key-Id`     | `wms-prod-1`        | Identifier the receiver uses to look up the shared secret. Also implicitly identifies the sender. |
| `X-Shelfbot-Timestamp`  | `1745366400`        | Unix seconds (UTC) at the moment of signing.                                                      |
| `X-Shelfbot-Signature`  | `sha256=4f2a…`      | Lowercase hex HMAC-SHA256 of `<timestamp>.<raw_body>` using the shared secret.                    |
| `X-Shelfbot-Message-Id` | `01HZ…` (ULID/UUID) | Unique per request. Used for dedupe (see *Idempotency*).                                            |

#### 4.2.2 Verification (receiver)

1. Reject if any required header is missing → `400 { error.code: "AUTH_MISSING_HEADER" }`.
2. Look up the secret for `X-Shelfbot-Key-Id`. Unknown key → `401 { error.code: "AUTH_UNKNOWN_KEY" }`.
3. Reject if `|now - X-Shelfbot-Timestamp| > 300s` → `401 { error.code: "AUTH_TIMESTAMP_SKEW" }`.
4. Recompute HMAC over `<timestamp>.<raw_body>` and compare in constant time. Mismatch → `401 { error.code: "AUTH_SIGNATURE_INVALID" }`.
5. IP allowlisting MAY be enforced at the network edge, reverse proxy, API gateway, firewall, or application layer. The implementation is deployment-specific and does not form part of the API contract; HMAC signatures over TLS are the authentication mechanism of the API. Allowlisting is available where required by customer security policy.

#### 4.2.3 Identity

The sender is identified implicitly from `X-Shelfbot-Key-Id`. Payloads MUST NOT carry a source / vendor field — receivers derive sender identity from auth and use it for tagging (e.g. Shelfbot tags inventory rows with the WMS name mapped from the key id).

#### 4.2.4 Secrets handling

* HMAC secrets MUST be stored in environment variables or a secrets manager. Never in version control.
* Secrets MUST be ≥32 random bytes.
* URLs MAY live in env vars or config files.

Recommended Shelfbot-side env var names:

```
WMS_BASE_URL          # e.g. https://wms.example.com
WMS_HMAC_KEY_ID       # identifier the WMS uses to look up its secret
WMS_HMAC_SECRET       # shared secret
```

Recommended WMS-side env var names (symmetric):

```
SHELFBOT_BASE_URL
SHELFBOT_HMAC_KEY_ID
SHELFBOT_HMAC_SECRET
```

#### 4.2.5 Webhook URL discovery

Static config on both sides. Each peer's base URL + key id + secret is configured at deploy time. No runtime registration endpoint in v1.

### 4.3 Idempotency

* Every request carries `X-Shelfbot-Message-Id` (ULID or UUID recommended).
* Receivers MUST cache the result of each successfully-processed message id for 24 hours.
* A replay (same id within window) MUST return the cached response with the same status code and body, without re-processing.
* For batched requests (see *Batching*), the message id covers the entire batch. On Shelfbot-hosted endpoints a replay returns the cached per-item results array; for event deliveries to the WMS, a replay is acknowledged with 2xx like the original.

### 4.4 Batching

Every endpoint accepts an array, even for a single item. There is no single-item shorthand.

```json
{ "skus": [ { ... }, { ... } ] }
```

| Rule                | Value                                                            |
| ------------------- | ---------------------------------------------------------------- |
| Max items per batch | 100 — exceed → `400 { error.code: "BATCH_TOO_LARGE" }`           |
| Dedupe scope        | The whole batch shares one `X-Shelfbot-Message-Id`               |
| Partial failure     | Allowed. Returns `200 OK`; per-item outcomes in `data.results[]` |

#### 4.4.1 Per-item result shape

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "results": [
      { "index": 0, "id": 1234567890, "ok": true },
      { "index": 1, "id": 1234567891, "ok": false,
        "error": { "code": "INVENTORY_NONZERO", "message": "...", "details": { "sku": "BOLT_M5", "inventory": 4 } } }
    ]
  }
}
```

Sender retries only items where `ok=false` AND `error.code` is in the retryable set (see *Retry decision*).

Partial failure and per-item results apply to requests into Shelfbot. Event deliveries to the WMS are acknowledged as a whole batch with any HTTP 2xx response: see *Acknowledgement* under the Shelfbot to WMS endpoints.

### 4.5 Response envelope

Every response — success or error — uses the same envelope.

This envelope applies to responses from Shelfbot-hosted endpoints, both writes and reads. For event deliveries to the WMS, no response body is required and any body is ignored: see *Acknowledgement* under the Shelfbot to WMS endpoints.

#### 4.5.1 Success

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": { ... }
}
```

#### 4.5.2 Error

```json
{
  "ok": false,
  "message_id": "01HZ...",
  "error": {
    "code": "INVENTORY_NONZERO",
    "message": "Cannot delete SKU with inventory > 0",
    "details": { "sku": "BOLT_M5", "inventory": 4 }
  }
}
```

#### 4.5.3 Standard error codes

| Code                     | HTTP | Meaning                                       |
| ------------------------ | ---- | --------------------------------------------- |
| `AUTH_MISSING_HEADER`    | 400  | Required auth header absent                   |
| `AUTH_UNKNOWN_KEY`       | 401  | `X-Shelfbot-Key-Id` not recognised            |
| `AUTH_TIMESTAMP_SKEW`    | 401  | Timestamp too far from receiver clock (>300s) |
| `AUTH_SIGNATURE_INVALID` | 401  | HMAC mismatch                                 |
| `BODY_TOO_LARGE`         | 413  | Request body > 1 MB                           |
| `BATCH_TOO_LARGE`        | 400  | More than 100 items in batch                  |
| `BAD_REQUEST`            | 400  | Malformed JSON or schema violation            |
| `UNSUPPORTED_MEDIA_TYPE` | 415  | Content-Type not `application/json`           |
| `INTERNAL_ERROR`         | 500  | Receiver-side failure (DB down, etc.)         |

Per-item error codes are listed under each endpoint.

### 4.6 Retry policy (sender)

#### 4.6.1 Cadence

Exponential backoff, 7 attempts: 30s, 1m, 5m, 15m, 1h, 6h, 24h (\~32h total). After the final attempt, drop into a dead-letter queue for human review.

#### 4.6.2 Retry decision

| Outcome                          | Action                                                                                                         |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| 2xx                              | Success. Drop from queue. (For batches: retry only items with retryable per-item errors in a follow-up batch.) |
| 408, 429, 5xx, network/TLS error | Retry per cadence above                                                                                        |
| 4xx (other than 408/429)         | Permanent failure. Don't retry. Log/alert.                                                                     |

429 responses SHOULD include a `Retry-After` header; the sender uses `max(Retry-After, next backoff)`.

## 5. Endpoints — WMS → Shelfbot

Shelfbot's data model is flat: every stockable item is a SKU. There is no product/variant hierarchy — each SKU stands alone with its own barcode, image, tags, and inventory.

The WMS-to-Shelfbot direction carries SKU catalogue sync and order lifecycle only. Physical-state changes (picks, stock-in, adjustments) always originate at Shelfbot and flow the other way (*Endpoints — Shelfbot → WMS*).

### 5.1 POST /v1/sku/update

**Direction:** WMS → Shelfbot
**Purpose:** Create or update SKUs in Shelfbot.
**Trigger:** WMS emits whenever a SKU is created or modified. Typical use is a 60s background sync.

**Request:**

```json
{
  "skus": [
    {
      "sku": "BOLT_M5_10HST_BLACK",
      "title": "BOLT M5 10HST Black",
      "barcode": "5012345678900",
      "alt_barcodes": ["05012345678900", "SUP-BM5-10"],
      "supplier_sku": "SUP-BM5-10",
      "inventory_quantity": 0,
      "external_id": 1234567891,
      "tags": ["FASTENER", "METRIC"],
      "pick_strategy": "fifo",
      "hazard_classes": ["flammable"],
      "batch_tracked": false,
      "serial_tracked": false,
      "requires_expiry": false,
      "batch_scan": "per_receipt",
      "image": { "id": 1234567892, "src": "https://wms.example.com/img/bolt-m5.jpg" },
      "tote_division": 24,
      "each":  { "length_mm": 10, "width_mm": 5,  "height_mm": 5,  "weight_g": 2 },
      "inner": { "length_mm": 100, "width_mm": 50, "height_mm": 50, "weight_g": 25,  "quantity": 10 },
      "outer": { "length_mm": 300, "width_mm": 200,"height_mm": 200,"weight_g": 280, "quantity": 100 }
    }
  ]
}
```

**Fields:**

| Field                       | Type         | Req | Meaning                                                                                                                                                                 |
| --------------------------- | ------------ | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `skus[].sku`                | string       | yes | Primary key. Case-sensitive identifier used everywhere Shelfbot references this item.                                                                                   |
| `skus[].title`              | string       | yes | Human-readable display name shown in the Shelfbot UI.                                                                                                                   |
| `skus[].barcode`            | string       | no  | Primary scanned barcode value. Used to match a physical scan to this SKU. Empty string \= no barcode.                                                                   |
| `skus[].alt_barcodes`       | string\[]    | no  | Additional barcodes that also identify this SKU. Must not collide with another SKU's primary or alt barcodes.                                                           |
| `skus[].supplier_sku`       | string       | no  | The supplier's own SKU code for this item. Opaque to Shelfbot.                                                                                                          |
| `skus[].inventory_quantity` | int          | no  | WMS-side stock count, for reference only. Shelfbot tracks its own counts independently.                                                                                 |
| `skus[].external_id`        | int\|string  | no  | The WMS's own internal id for this SKU. Opaque to Shelfbot — stored and echoed back in events.                                                                          |
| `skus[].tags`               | string\[]    | no  | Free-form classification tags. Shelfbot auto-adds the sender's identity tag (derived from the HMAC key id) on top of these.                                             |
| `skus[].pick_strategy`      | enum         | no  | One of `"fifo"`, `"lifo"`, `"fefo"`, `"random"`. `fefo` picks earliest expiry first, ties broken by receipt order, with undated stock sorted after every dated batch. Unset: `fefo` for SKUs with `requires_expiry: true`, otherwise the per-deployment default (typically fifo). |
| `skus[].hazard_classes`     | string\[]    | no  | Handling-rule classifications. Recommended vocabulary: `"flammable"`, `"corrosive"`, `"oxidizer"`, `"toxic"`, `"magnetic"`, `"fragile"`, `"esd"`, `"sharp"`, `"heavy"`. |
| `skus[].batch_tracked`      | bool         | no  | Default false. If true, every stocked unit is associated with a batch number.                                                                                           |
| `skus[].serial_tracked`     | bool         | no  | Default false. If true, every unit has a unique serial number that must be scanned individually.                                                                        |
| `skus[].requires_expiry`    | bool         | no  | Default false. Only meaningful when `batch_tracked: true`. If true, every batch record MUST carry an `expires_at` date.                                                 |
| `skus[].batch_scan`         | enum         | no  | Only meaningful when `batch_tracked: true`. One of `"per_receipt"` (default) or `"per_unit"`.                                                                           |
| `skus[].image`              | object\|null | no  | Single product photo. Object shape `{ id, src }`. Shelfbot only re-downloads when `id` changes.                                                                         |
| `skus[].tote_division`      | int          | no  | Suggested tote compartment layout, encoded as `RC` (rows × cols). Shelfbot uses this internally.                                                                        |
| `skus[].each`               | object       | no  | Dimensions and weight of a single unit. `{ length_mm, width_mm, height_mm, weight_g }`.                                                                                 |
| `skus[].inner`              | object       | no  | Dimensions, weight, and per-pack quantity of an inner pack.                                                                                                             |
| `skus[].outer`              | object       | no  | Dimensions, weight, and per-pack quantity of an outer pack.                                                                                                             |

**Behaviour:**

* For each SKU, upsert by `sku`.
* Replace the SKU's tag set with the union of `skus[].tags` and the sender's identity tag. (Tags from other senders are not affected — they live on a per-(sender, sku) basis.)
* If `image.id` differs from the cached one, schedule an async download of `image.src`.
* `tote_division`, `each`, `inner`, `outer` are stored as-is for Shelfbot's internal use; they don't trigger any side effects on update.
* Changing `batch_tracked` or `serial_tracked` on a SKU that already holds inventory is refused with `TRACKING_CHANGE_WITH_INVENTORY` — the SKU must be fully depleted first.
* This endpoint never deletes SKUs. To remove a SKU, use `/v1/sku/delete`.

**Tracking invariants (Shelfbot-internal, exposed here so WMS understands refusals):**

* **One batch per compartment.** A compartment holds exactly one batch at a time. Stocking a second batch into an occupied compartment is refused.
* **Serial uniqueness (per-SKU, in-stock only).** For serial-tracked SKUs, no two currently-in-stock units of the same SKU may share a serial. Different SKUs may share serial values freely. A serial that has been previously picked MAY be re-stocked (returns / RMA).
* **Batch at induct, product at pick.** For batch-tracked SKUs the batch, and the expiry where the SKU requires one, is established when stock enters a compartment: read from the carton's GS1 barcode, resolved from a carton record (*POST /v1/carton/update*), or keyed by the runner. A compartment holds one batch, so a pick inherits it: the pick scans the product barcode and no batch scan is required. Where a unit barcode does carry batch or expiry, Shelfbot may read it as a confirmation, and a disagreement with the compartment's batch is reported as a `scan.error` exception.
* **Serials at pick.** For serial-tracked SKUs, and for lines sent with `capture_serials: true`, every unit's serial is scanned at pick. A unit without a serial barcode has its serial keyed by the operator, and `line.picked` lists it under `serials_keyed` as well as in the serials it reports.
* **No auto-decrement.** Shelfbot never auto-decrements batch- or serial-tracked stock: the quantity taken is confirmed by scan or count at the station.
* **`batch_scan`.** `per_receipt` (default): batch and expiry are recorded once per stock-in and apply to every unit stocked from that carton into the compartment; units are counted, not individually scanned for batch. `per_unit`: for products whose unit barcode carries batch and expiry; each unit is scanned as it enters the compartment and must agree with the compartment's batch or the induct is refused, and at pick the unit scan confirms the batch as well as the product.
* **Expiry is always reported.** For a SKU with `requires_expiry: true`, every batch entry Shelfbot emits or serves carries `expires_at`: in `line.picked`, the order read, the bin read, `inventory.stocked` and shipper manifests. It is the date recorded at induct for that batch, as a date only (`YYYY-MM-DD`), so it is the same value wherever that batch appears. For other batch-tracked SKUs it is present whenever it was captured.
* **Expired and blocked stock.** Stock past its expiry, or of a batch the WMS has blocked (*POST /v1/batch/update*), is excluded from strategy-driven picking and from preferred-batch fallback, including for lines already open when the block arrives: such a line draws on other batches if it can and otherwise closes short. Only a line that names the batch with `batch_policy: "required"`, typically a quarantine order, picks it. Blocked stock is refused at induct. Excluded stock stays in place and in the count until removed: by such an order, or at the station as an `inventory.adjusted` with reason `expired` or `recall`. Nothing is auto-decremented on expiry or on a block.

**Per-item errors:**

| Code                             | Meaning                                                                                              |
| -------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `INVALID_SKU`                    | `sku` field empty or contains disallowed characters                                                  |
| `IMAGE_INCOMPLETE`               | `image` object present but missing `id` or `src`                                                     |
| `INVALID_BIN_DIVISION`           | `tote_division` not in RC form with R,C ∈ 1–9                                                        |
| `INVALID_DIMENSIONS`             | A dimension/weight field is negative, non-numeric, or `inner`/`outer` is missing required `quantity` |
| `INVALID_PICK_STRATEGY`          | `pick_strategy` not one of `fifo`, `lifo`, `fefo`, `random`                                                  |
| `BARCODE_COLLISION`              | `barcode` or an entry in `alt_barcodes` is already claimed by a different SKU                        |
| `INVALID_BATCH_SCAN`             | `batch_scan` not one of `per_receipt`, `per_unit`                                                    |
| `TRACKING_CHANGE_WITH_INVENTORY` | Attempted to change `batch_tracked` or `serial_tracked` while Shelfbot still holds stock             |

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "results": [
      { "index": 0, "sku": "BOLT_M5_10HST_BLACK", "ok": true, "created": false, "image_queued": true }
    ]
  }
}
```

### 5.2 POST /v1/sku/delete

**Direction:** WMS → Shelfbot
**Purpose:** Delete one or more SKUs from Shelfbot.
**Trigger:** WMS emits when a SKU is removed upstream.

**Request:**

```json
{
  "skus": [
    { "sku": "BOLT_M5_10HST_BLACK", "force": false }
  ]
}
```

**Fields:**

| Field          | Type   | Req | Notes                                                              |
| -------------- | ------ | --- | ------------------------------------------------------------------ |
| `skus[].sku`   | string | yes | SKU to delete                                                      |
| `skus[].force` | bool   | no  | Default false. If true, delete even when Shelfbot holds inventory. |

**Behaviour:**

* Look up each SKU.
* If the SKU has inventory > 0 anywhere in Shelfbot and `force != true`, refuse with `INVENTORY_NONZERO`.
* Scope guard: Shelfbot only deletes SKUs tagged with the sender's identity tag. SKUs owned by other senders, or untagged, are refused with `NOT_OWNED`.
* On success: delete the SKU, its tags, and its image references.

**Per-item errors:**

| Code                | Meaning                                                 |
| ------------------- | ------------------------------------------------------- |
| `INVENTORY_NONZERO` | SKU has inventory and `force` was not set               |
| `NOT_OWNED`         | SKU exists but is not tagged with the sender's identity |
| `UNKNOWN_SKU`       | No SKU with that key                                    |

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "results": [
      { "index": 0, "sku": "BOLT_M5_10HST_BLACK", "ok": true }
    ]
  }
}
```

### 5.3 POST /v1/order/update

**Direction:** WMS → Shelfbot
**Purpose:** Send a pick order to Shelfbot, or change its state (fulfil / cancel).
**Trigger:** WMS emits on order creation, on cancellation, or any time the line-item set or quantities change.

**Request:**

```json
{
  "orders": [
    {
      "id": 1739900000000,
      "name": "Job 4521",
      "state": "open",
      "priority": true,
      "line_items": [
        { "id": 900123456, "sku": "BOLT_M5_10HST_BLACK", "quantity": 4 },
        { "id": 900123457, "sku": "NUT_M5_NYL",          "quantity": 4 },
        { "id": 900123458, "sku": "MULTIMETER_FL87V",    "quantity": 1, "serials": ["FL87V-2024-0042"] },
        { "id": 900123459, "sku": "PAINT_RAL5010_1L",    "quantity": 6, "batches": [ { "batch_number": "L240501", "expires_at": "2027-05-01" } ], "batch_policy": "preferred" }
      ]
    }
  ]
}
```

To fulfil: send the same payload with `"state": "fulfilled"`. To cancel: send the same payload with `"state": "cancelled"`. To partially refund a line: re-send with the line's `quantity` lowered (or 0 to skip it entirely).

**Fields:**

| Field                            | Type      | Req | Meaning                                                                                                                                                                                                                                                                                                                                               |
| -------------------------------- | --------- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `orders[].id`                    | int       | yes | Stable order id chosen by the WMS. Primary key for upsert.                                                                                                                                                                                                                                                                                            |
| `orders[].name`                  | string    | yes | Free-form display name shown in the Shelfbot UI.                                                                                                                                                                                                                                                                                                      |
| `orders[].state`                 | enum      | yes | One of `"open"`, `"fulfilled"`, `"cancelled"`.                                                                                                                                                                                                                                                                       |
| `orders[].priority`              | bool      | no  | Scheduling hint. `true` places the order ahead of normal orders in the pick queue; it influences sequencing and is not a completion-time guarantee. Absent on create means `false`. Absent on a later update leaves the current value unchanged.                                                                                                                                                                                                                                                                                                        |
| `orders[].line_items[].id`       | int       | yes | Stable line id chosen by the WMS. Primary key for line upsert within the order.                                                                                                                                                                                                                                                                       |
| `orders[].line_items[].sku`      | string    | yes | Must reference an existing SKU in Shelfbot.                                                                                                                                                                                                                                                                                                           |
| `orders[].line_items[].quantity` | int       | yes | Number of units to pick. Authoritative — re-sending with a lower value reduces the pick target. 0 means the line is not picked.                                                                                                                                                                                                                       |
| `orders[].line_items[].serials`  | string\[] | no  | Only meaningful when the SKU has `serial_tracked: true`. If present, Shelfbot MUST ship these exact serials and no others (length must equal `quantity`). Used when the WMS has promised a specific unit (RMA replacement, demo unit, customer-reserved). If absent, Shelfbot picks any in-stock serials and reports them in the `line.picked` event. |
| `orders[].line_items[].batches`  | array     | no  | Only meaningful when the SKU has `batch_tracked: true`. Each entry is a batch number (string) or `{ batch_number, expires_at? }`, where `expires_at` is the shelf-life date the WMS holds for that batch (`YYYY-MM-DD`). The batch number is the identity; a supplied date is a cross-check against the expiry Shelfbot recorded at induct, and a disagreement is accepted and reported as a `batch.mismatch` exception. How the named batches are used is set by `batch_policy`. If absent, Shelfbot picks per the SKU's pick strategy. |
| `orders[].line_items[].batch_policy` | enum  | no  | `"preferred"` (default when `batches` is present) or `"required"`. Preferred: pick from the named batches first; if they cannot cover the line, the remainder follows the SKU's pick strategy and the actual batches picked are reported on `line.picked`; the order is always accepted. Required: pick only from the named batches; refused at acceptance with `BATCH_NOT_AVAILABLE` if they cannot cover `quantity`, and a later shortfall closes the line short rather than substituting. Used for customer-mandated batches and for quarantine pulls of a blocked or expired batch. Ignored when `batches` is absent. |
| `orders[].line_items[].min_expires_at` | string | no | Date (`YYYY-MM-DD`). Only meaningful when the SKU has `requires_expiry: true`. Strategy-driven picking and preferred-batch fallback select only stock expiring on or after this date; a batch named in `batches` is picked regardless of the floor. A line with nothing qualifying closes with status `short`. |
| `orders[].line_items[].capture_serials` | bool | no  | When `true`, every pick against this line requires a serial-number scan, and the picked serials are reported back to the WMS in `line.picked` events and the order read, even when the SKU is not `serial_tracked`. It does not constrain which units are picked (`serials` does that). Absent on create means `false`. Absent on a later update leaves the current value unchanged. |

**Behaviour:**

* Upsert the order by `id`. New orders (state: `"open"`) enter the pick queue.
* Priority orders are scheduled ahead of normal orders whenever Shelfbot chooses the next bin presentations. A change to `priority` on an open order takes effect from the next scheduling decision; presentations already in flight are not interrupted.
* A line sent with `capture_serials: true` adds a serial scan to each pick: the operator scans the unit's serial number and Shelfbot reports it in the `line.picked` event. Reporting only; pick selection is unchanged.
* State transitions:
  * `open` → robot picks against the current line set.
  * `fulfilled` → Shelfbot stops picking and marks the order complete. The order's shipper labels become free for reuse (see *Shippers*).
  * `cancelled` → Shelfbot stops picking and marks the order abandoned (no fulfilment recorded). Cancellation is the WMS's alone: Shelfbot never cancels an order itself, and no `order.completed` follows a cancellation, since the WMS asked for it. Every open shipper closes with reason `order_cancelled` and its manifest, empty if nothing had been picked into it (see *Shippers*), and the order's labels become free for reuse.
* Each line item upserts by `id`. `quantity` is the live target — already-picked progress is preserved; only the remaining work changes.
* Lines previously sent that are absent from a subsequent update are left unchanged. To remove a line, re-send with `quantity: 0`.
* After update, Shelfbot re-evaluates whether the order is complete and updates its pick queue.

**Per-item errors:**

| Code                    | Meaning                                                                                                     |
| ----------------------- | ----------------------------------------------------------------------------------------------------------- |
| `UNKNOWN_SKU`           | A line item references a SKU not present in Shelfbot                                                        |
| `EMPTY_ORDER`           | `line_items` is empty on a new order                                                                        |
| `INVALID_STATE`         | `state` not one of `open`, `fulfilled`, `cancelled`                                                         |
| `INVALID_TRANSITION`    | Attempted to move a fulfilled or cancelled order back to open                                               |
| `SERIAL_NOT_AVAILABLE`  | A specified serial is not currently in stock for that SKU                                                   |
| `SERIAL_COUNT_MISMATCH` | `serials[]` length does not equal `quantity`                                                                |
| `BATCH_NOT_AVAILABLE`   | With `batch_policy: "required"`: a specified batch has no in-stock units, or the requested batches collectively hold fewer than `quantity` units |
| `INVALID_BATCH_POLICY`  | `batch_policy` not one of `preferred`, `required`                                                                    |
| `INVALID_DATE`          | A batch `expires_at` or `min_expires_at` is not a `YYYY-MM-DD` date                                                 |

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "results": [
      { "index": 0, "id": 1739900000000, "ok": true, "state": "open", "lines_remaining": 8 }
    ]
  }
}
```

`state` echoes Shelfbot's view after the update. `lines_remaining` is the total unpicked quantity across all lines.

### 5.4 POST /v1/carton/update

**Direction:** WMS → Shelfbot
**Purpose:** Tell Shelfbot what is inside labelled cartons before they reach the trolley, so receiving becomes scan-and-confirm.
**Trigger:** WMS emits when inbound carton contents become known (typically from the supplier's advance shipping notice), and on any correction.

Cartons are identified by an opaque `carton_id`: whatever unique code the carton label carries. For GS1-labelled freight that is the SSCC (Serial Shipping Container Code, application identifier 00); operations with their own labelling use their own codes, and Shelfbot treats both identically. When an operator scans a carton label at the trolley, Shelfbot resolves the contents from these records: the SKU, quantity, batch, and expiry are captured as declared rather than typed. Case labels that carry their contents in the barcode itself (GS1 application identifiers) need no carton record; this endpoint exists for cartons whose label is a bare SSCC.

**Request:**

```json
{
  "cartons": [
    {
      "carton_id": "003123450000000011",
      "reference": "PO-88231",
      "contents": [
        {
          "sku": "PAINT_RAL5010_1L",
          "quantity": 48,
          "batch": { "batch_number": "L240901", "expires_at": "2027-03-01" }
        }
      ]
    }
  ]
}
```

**Fields:**

| Field                           | Type      | Req         | Meaning                                                                                                                              |
| ------------------------------- | --------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------|
| `cartons[].carton_id`           | string    | yes         | The carton's label code, exactly as encoded on the label (1 to 64 characters). Typically a GS1 SSCC; any unique labelling scheme is accepted. Primary key for upsert. |
| `cartons[].reference`           | string    | no          | Optional reference (purchase order, ASN or delivery id). Echoed for traceability.                                                    |
| `cartons[].contents[]`          | array     | yes         | One entry per SKU in the carton; at least one entry.                                                                                 |
| `cartons[].contents[].sku`      | string    | yes         | SKU contained.                                                                                                                       |
| `cartons[].contents[].quantity` | int       | yes         | Units of this SKU in the carton. Positive integer.                                                                                   |
| `cartons[].contents[].batch`    | object    | conditional | Required if the SKU is batch-tracked. Shape: `{ batch_number, expires_at?, produced_at?, supplier_batch_ref? }`.                     |
| `cartons[].contents[].serials`  | string\[] | conditional | For serial-tracked SKUs: the serials in the carton (length must equal `quantity`).                                                   |

**Behaviour (receiver / Shelfbot):**

* Upsert by `carton_id`: re-sending a carton replaces its declared contents.
* Carton records are **reference data, not inventory**. Stock moves only when an `inventory.stocked` event says so; carton records carry what a carton is *declared* to contain, and Shelfbot never reconciles inventory against them.
* Carton records are **self-expiring**. Shelfbot MAY discard a record once its carton has been fully received, and discards records not referenced within 120 days. There is no carton delete endpoint; a correction is a re-send.
* An empty or over-long `carton_id`, an empty `contents` array, a non-positive quantity, or a serial list whose length does not match its quantity is refused per item with `BAD_REQUEST`. The identifier is not validated against any labelling standard: resolution at the trolley is an exact match between the scanned label and the declared code.
* The stock-in that consumes a carton reports the carton identifier back on its `inventory.stocked` event (see the `carton_id` field there), closing the loop: the WMS receives a per-carton goods-receipt confirmation.

**Response:** the standard envelope with per-item results, one per carton, in order.

### 5.5 POST /v1/batch/update

**Direction:** WMS → Shelfbot
**Purpose:** Block or release a batch, so that a recall or a quality hold decided in the WMS takes effect inside Shelfbot.
**Trigger:** WMS emits when it changes a batch's status: a supplier or regulatory recall, a quality hold, or the release of either.

Shelfbot cannot see the WMS's batch status, and its own pick strategies and preferred-batch fallback would otherwise keep offering a batch the WMS has blocked. This call closes that gap. A blocked batch is excluded from strategy-driven picking and from fallback, including for lines already open, is refused at induct if more of it arrives, and is picked only by a line that names it with `batch_policy: "required"`: the quarantine pull. Blocked and expired stock share one rule (see *Expired and blocked stock* under *POST /v1/sku/update*).

**Request:**

```json
{
  "batches": [
    { "sku": "PAINT_RAL5010_1L", "batch_number": "L240501", "status": "blocked", "reason": "recall", "reference": "TGA-RC-2026-0147" }
  ]
}
```

**Fields:**

| Field                     | Type   | Req | Meaning                                                                                                                     |
| ------------------------- | ------ | --- | --------------------------------------------------------------------------------------------------------------------------- |
| `batches[].sku`           | string | yes | The SKU. Batch numbers are unique only within a SKU, so both are required.                                                  |
| `batches[].batch_number`  | string | yes | The batch. Together with `sku`, the primary key for upsert.                                                                 |
| `batches[].status`        | enum   | yes | `"blocked"` or `"released"`.                                                                                                |
| `batches[].reason`        | enum   | no  | `"recall"`, `"hold"` or `"other"`. Shown to operators and in the Portal.                                                    |
| `batches[].reference`     | string | no  | The WMS's or regulator's reference for the action. Echoed on the batch lookup.                                              |

**Behaviour (receiver / Shelfbot):**

* Upsert by `sku` and `batch_number`: the latest status wins, and re-sending the same status is a no-op.
* A block is accepted for a batch Shelfbot does not currently hold, so a recall that arrives before the goods do bites at receiving: the runner is refused when that batch is scanned at induct.
* `released` returns the batch to normal picking and induct. A block never deletes stock or moves it; removal is a quarantine order or a station adjustment, and the count moves only through `inventory.stocked` and `inventory.adjusted` events.
* An unknown SKU is refused per item with `UNKNOWN_SKU`; a status or reason outside the enumerations with `INVALID_STATUS` or `INVALID_REASON`.

**Response:** the standard envelope with per-item results, one per batch, in order.

## 6. Endpoints — Reading current state

Shelfbot hosts a set of GET endpoints that let the WMS read the state Shelfbot currently holds. All read endpoints are hosted by Shelfbot and called by the WMS. Reads are optional: an integration is complete without them (see the conformance statement in *Introduction*), but they support receipt checks after catalogue pushes, catalogue reconciliation, on-demand order queries triggered by events, and bin lookups.

### 6.1 Authentication and envelope

GET requests use the same HMAC scheme defined in *Authentication*. The request body is empty, so the signature is computed over `<timestamp>.` (the timestamp followed by a single dot, with nothing after it). All four headers are still required.

GET requests carry no request body and need not send a `Content-Type` header; responses are `application/json; charset=utf-8` as everywhere else. Read responses use the standard envelope defined in *Response envelope*, with the endpoint's payload in `data`.

### 6.2 GET /v1/skus

**Direction:** WMS → Shelfbot (read)
**Purpose:** Paginated listing of the SKU codes Shelfbot currently holds for the caller.

**Query parameters:**

| Param           | Type          | Req | Meaning                                                                                                                                                                                                                                 |
| --------------- | ------------- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `updated_since` | int or string | no  | Unix seconds or an ISO 8601 date. Returns only SKUs whose `updated_at` is at or after this time. `updated_at` records when Shelfbot applied the WMS's most recent change, so this filter is a receipt check: "did my recent pushes land". |
| `order`         | enum          | no  | `recent` returns SKUs most-recent-first by `updated_at`. Default ordering is bytewise by SKU code, matching the sort used by the catalogue summary hash.                                                                                  |
| `limit`         | int           | no  | Page size. Max 1000, default 500.                                                                                                                                                                                                         |
| `cursor`        | string        | no  | Opaque cursor from a previous page. Omit for the first page.                                                                                                                                                                              |

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "skus": [
      { "sku": "BOLT_M5_10HST_BLACK", "updated_at": "2026-08-30T09:12:44.000Z" }
    ],
    "cursor": "eyJvZmZzZXQiOjUwMH0"
  }
}
```

`cursor` is `null` on the last page. The listing is scoped to the caller's SKUs (those tagged with the sender's identity, as in *POST /v1/sku/delete*).

Deleted SKUs never appear in this listing. There are no deletion tombstones: a deletion is invisible to `updated_since`. Deletion drift is detected with the catalogue summary (*GET /v1/skus/summary*) and resolved by list comparison.

### 6.3 GET /v1/skus/summary

**Direction:** WMS → Shelfbot (read)
**Purpose:** Compact summary of the caller's SKU catalogue as Shelfbot holds it, for drift detection.

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "count": 1841,
    "hash": "sha256:9f2c4e...",
    "algo": "sku-set-sha256-v1",
    "as_of": "2026-08-30T09:15:00.000Z"
  }
}
```

| `data` field | Type   | Meaning                                                          |
| ------------ | ------ | ----------------------------------------------------------------- |
| `count`      | int    | Number of SKUs Shelfbot holds for the caller.                    |
| `hash`       | string | Hash of the caller's SKU code set, computed per `algo`.          |
| `algo`       | string | Hash algorithm label. `"sku-set-sha256-v1"` in this version.     |
| `as_of`      | string | ISO 8601 UTC timestamp of the state the summary describes.       |

`sku-set-sha256-v1` is defined exactly as: SHA-256 over the bytewise-sorted SKU codes joined with a newline character, UTF-8 encoded, hex output prefixed `sha256:`.

The summary detects membership drift only, by design. Attribute drift is cured by re-pushing: upserts through *POST /v1/sku/update* are idempotent.

Reconciliation is WMS-driven. The WMS computes the same hash over its own SKU code set and compares it with `hash`. On mismatch, it pages through *GET /v1/skus*, diffs against its own catalogue, re-pushes missing SKUs via *POST /v1/sku/update*, and removes strays via *POST /v1/sku/delete* (subject to the `INVENTORY_NONZERO` refusal). Deletion authority stays with the WMS; Shelfbot never deletes SKUs on its own initiative.

### 6.4 GET /v1/sku

**Direction:** WMS → Shelfbot (read)
**Purpose:** Read one SKU as Shelfbot currently holds it.

**Query parameters:** `sku=CODE` (required). The SKU code is passed as a query parameter rather than a path segment because SKU codes may contain characters hostile to URL paths.

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "sku": { "sku": "BOLT_M5_10HST_BLACK", "title": "BOLT M5 10HST Black", "...": "remaining fields as accepted by /v1/sku/update" },
    "delivered": true,
    "updated_at": "2026-08-30T09:12:44.000Z"
  }
}
```

`data.sku` carries the same fields accepted by *POST /v1/sku/update*, with the values Shelfbot currently holds. `delivered` is `true` when the site has acknowledged the latest push for this SKU. `updated_at` is when Shelfbot applied the most recent change.

A missing `sku` parameter returns HTTP `400` with code `BAD_REQUEST`. An unknown code returns HTTP `404` with the error envelope and code `UNKNOWN_SKU`.

### 6.5 GET /v1/orders/{id}

**Direction:** WMS → Shelfbot (read)
**Purpose:** Read the current execution state of an order.

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "id": 1739900000000,
    "name": "Job 4521",
    "state": "open",
    "priority": true,
    "final_state": null,
    "lines": [
      {
        "line_id": 900123456,
        "sku": "BOLT_M5_10HST_BLACK",
        "requested_quantity": 4,
        "picked_quantity": 2,
        "remaining_quantity": 2,
        "status": "partial",
        "serials": [],
        "batches": [ { "batch_number": "L240501", "quantity": 2, "expires_at": "2027-05-01" } ]
      }
    ],
    "shippers": [
      { "shipper_seq": 1, "shipper_id": "SHP-00045-1", "status": "closed", "reason": "full",
        "opened_at": "2026-04-23T10:11:40.000Z", "closed_at": "2026-04-23T10:14:05.000Z",
        "contents": [ { "line_id": 900123456, "sku": "BOLT_M5_10HST_BLACK", "quantity": 2,
                        "batches": [ { "batch_number": "L240501", "quantity": 2, "expires_at": "2027-05-01" } ], "serials": [] } ] },
      { "shipper_seq": 2, "shipper_id": "SHP-00045-2", "status": "open", "reason": null,
        "opened_at": "2026-04-23T10:14:20.000Z", "closed_at": null, "contents": [] }
    ],
    "accepted_at": "2026-04-23T10:11:02.000Z",
    "completed_at": null,
    "as_of": "2026-04-23T10:16:00.000Z"
  }
}
```

`state` mirrors the order state enum in *POST /v1/order/update*. `priority` echoes the current value of the flag set there (`false` when never set). `final_state` is `null` until the order reaches a terminal state, then carries the same value as the `order.completed` event's `final_state`. Per-line `status` uses the same enum as `line.picked`. `serials` and `batches` accumulate what has been picked so far, each batch entry carrying its `expires_at` per *Expiry is always reported*; the line's `batch_policy` and `min_expires_at` are echoed as sent. `shippers` lists every shipper the order has used, in `shipper_seq` order: its label, `status` (`open` or `closed`), the close `reason` (`full`, `order_complete`, `order_cancelled`, or `null` while open), timestamps, and `contents`, which grows as units are put in and is final once the shipper closes, matching its `shipper.closed` event. An order picked into a single shipper shows one entry (see *Shippers*).

This endpoint is served from state Shelfbot maintains at its edge, materialised from the event stream. It may lag live activity by moments; a read issued after an event has been received is guaranteed not to be older than that event. When the site is offline, the endpoint keeps answering with last-known state, and `as_of` shows how fresh that state is. Completed orders remain readable for 90 days after completion.

An unknown id returns HTTP `404` with the error envelope and code `UNKNOWN_ORDER`.

### 6.6 GET /v1/bins/{bin_id}

**Direction:** WMS → Shelfbot (read)
**Purpose:** Read the current contents of a bin.

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "bin_id": 40213,
    "contents": [
      {
        "sku": "PAINT_RAL5010_1L",
        "quantity": 6,
        "batches": [ { "batch_number": "L240501", "quantity": 6, "expires_at": "2027-05-01", "status": "active" } ],
        "serials": []
      }
    ],
    "as_of": "2026-04-23T10:16:00.000Z"
  }
}
```

Each batch entry carries its `status`: `active`, `blocked` (*POST /v1/batch/update*) or `expired`. Bins are Shelfbot's physical domain; the WMS may read bin state but never commands bin placement.

An unknown bin id returns HTTP `404` with the error envelope and code `UNKNOWN_BIN`.

### 6.7 GET /v1/shippers/{shipper_id}

**Direction:** WMS → Shelfbot (read)
**Purpose:** Resolve a shipper label to the order it currently holds, with its contents. Typically called when a packing station scans a shipper and the WMS wants to confirm whose order is inside.

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "shipper_id": "SHP-00045-1",
    "order_id": 1739900000000,
    "external_id": null,
    "shipper_seq": 1,
    "status": "closed",
    "reason": "full",
    "opened_at": "2026-04-23T10:11:40.000Z",
    "closed_at": "2026-04-23T10:14:05.000Z",
    "contents": [
      { "line_id": 900123456, "sku": "BOLT_M5_10HST_BLACK", "quantity": 2,
        "batches": [ { "batch_number": "L240501", "quantity": 2, "expires_at": "2027-05-01" } ], "serials": [] }
    ],
    "as_of": "2026-04-23T10:16:00.000Z"
  }
}
```

The `shipper_id` in the path is the label code exactly as printed, percent-encoded where it contains characters outside the unreserved URL set. Because labels are reused, the response is the label's current binding, which persists until the WMS closes the order (see *Shippers*). After that the label is free, and the lookup returns HTTP `404` with the error envelope and code `UNKNOWN_SHIPPER` until the label is inducted again. The endpoint is served from the same edge-materialised state as *GET /v1/orders/{id}*, with the same freshness rules.

### 6.8 GET /v1/batches

**Direction:** WMS → Shelfbot (read)
**Purpose:** Locate a batch: how much Shelfbot holds, in which bins, with its expiry and status. The locate and reconcile steps of a recall, before the quarantine pull and after it.

**Query parameters:** `sku=CODE` (required) and `batch_number=...` (optional). Without `batch_number` the response lists every batch Shelfbot holds or has a status for under that SKU. Both are query parameters because batch numbers, like SKU codes, may contain characters hostile to URL paths.

**Response:**

```json
{
  "ok": true,
  "message_id": "01HZ...",
  "data": {
    "sku": "PAINT_RAL5010_1L",
    "batches": [
      {
        "batch_number": "L240501",
        "status": "blocked",
        "reason": "recall",
        "reference": "TGA-RC-2026-0147",
        "expires_at": "2027-05-01",
        "on_hand": 14,
        "bins": [ { "bin_id": 40213, "quantity": 6 }, { "bin_id": 40391, "quantity": 8 } ]
      }
    ],
    "as_of": "2026-04-23T10:16:00.000Z"
  }
}
```

`status` is `active`, `blocked` or `expired`; `reason` and `reference` are present for a blocked batch, as sent on *POST /v1/batch/update*. `on_hand` is the sum over `bins`. A batch that has been blocked before any of it arrived appears with `on_hand` 0 and no bins. After a quarantine pull the batch reads with `on_hand` 0, which is the reconciliation evidence; a non-zero figure after the pull is stock the pull missed.

Served from the same edge-materialised state as the bin read, with the same freshness rules. An unknown SKU returns HTTP `404` with the error envelope and code `UNKNOWN_SKU`; a `batch_number` Shelfbot has never held or been sent a status for returns `404` with code `UNKNOWN_BATCH`.

## 7. Endpoints — Shelfbot → WMS

Shelfbot → WMS endpoints reuse the auth, batching, idempotency, and retry rules defined in *Protocol*. The HMAC key id in this direction identifies Shelfbot to the WMS. Acknowledgement in this direction is simpler than the full response envelope; see *Acknowledgement* below.

### 7.1 Generation and delivery

Shelfbot creates events as operational transactions are committed. Delivery is asynchronous: for efficiency, multiple queued events may be delivered in a single request. Batching for transport never alters each event's `occurred_at` or semantics.

For example, a single POST may carry three events: two `line.picked` events from bin presentations thirty seconds apart, and one `inventory.stocked` event. Each keeps its own `occurred_at`:

```json
{
  "events": [
    { "event_id": "01J6W0A0A0A0A0A0A0A0A0A0A1", "occurred_at": "2026-08-30T10:15:00.000Z",
      "type": "line.picked", "order_id": 1739900000000, "external_id": null,
      "data": { "line_id": 900123456, "sku": "BOLT_M5_10HST_BLACK", "requested_quantity": 8,
                "picked_quantity": 4, "status": "partial", "bin_id": 40211 } },
    { "event_id": "01J6W0A0A0A0A0A0A0A0A0A0A2", "occurred_at": "2026-08-30T10:15:30.000Z",
      "type": "line.picked", "order_id": 1739900000000, "external_id": null,
      "data": { "line_id": 900123456, "sku": "BOLT_M5_10HST_BLACK", "requested_quantity": 8,
                "picked_quantity": 4, "status": "complete", "bin_id": 40212 } },
    { "event_id": "01J6W0A0A0A0A0A0A0A0A0A0A3", "occurred_at": "2026-08-30T10:16:05.000Z",
      "type": "inventory.stocked",
      "data": { "sku": "NUT_M5_NYL", "delta": 200, "new_count": 417, "operator": "user-17", "bin_id": 40213 } }
  ]
}
```

A committed transaction may emit multiple events, one per business fact: a pick, a stock-in, and an adjustment can share one bin visit. Corrections made before a transaction commits are not events. Net removals outside a pick are reported as `inventory.adjusted`. A net-zero change emits nothing.

The event vocabulary maps onto the WMS's own documents: `inventory.stocked` corresponds to the WMS's goods-receipt document; `inventory.adjusted` corresponds to its stock-correction document, with reason codes; picks are neither. Picks arrive only as order events and are never double-counted in inventory events.

### 7.2 Acknowledgement

Requests into Shelfbot (the WMS to Shelfbot endpoints and the read endpoints) return the full response envelope with per-item results, exactly as defined in *Protocol*. That contract is unchanged.

Event deliveries from Shelfbot to the WMS are acknowledged differently:

* Any HTTP 2xx response acknowledges the whole batch.
* No response body is required, and any body that is returned is ignored.
* A non-2xx response or a timeout follows the retry policy in *Retry policy (sender)*.
* Receivers SHOULD acknowledge promptly and process asynchronously.

A receiver that does not recognise an individual event (an unknown `order_id`, an unknown SKU, an unrecognised type) MUST still acknowledge the batch with 2xx and SHOULD log the event. A batch is never failed over one unrecognised event.

Receivers MUST acknowledge and ignore event types they do not process. New event types may be added in future versions of this specification, and ignoring them is always safe.

### 7.3 POST /v1/order/event

**Direction:** Shelfbot → WMS
**Purpose:** Report progress and terminal state of an order back to the WMS: line progress, line closure, the shippers it is picked into, order completion, failure.
**Trigger:** Shelfbot emits as picking progresses. A line may emit multiple `line.picked` events (see multi-event semantics below). One `order.completed` event fires when the order reaches a terminal state. Shipper events fire when a shipper is inducted for the order and when it closes.

**Request:**

```json
{
  "events": [
    {
      "event_id": "01HZQX5N4F8R7Y3K0M2D9V6BAE",
      "occurred_at": "2026-04-23T10:15:30.123Z",
      "type": "line.picked",
      "order_id": 1739900000000,
      "external_id": null,
      "data": {
        "line_id": 900123456,
        "sku": "BOLT_M5_10HST_BLACK",
        "requested_quantity": 4,
        "picked_quantity": 4,
        "status": "complete",
        "bin_id": 40211,
        "shipper_id": "SHP-00045-1",
        "shipper_seq": 1,
        "operator": "user-17"
      }
    },
    {
      "event_id": "01HZQX5N7Y2K8M3D0V6BAERFGH",
      "occurred_at": "2026-04-23T10:18:42.555Z",
      "type": "order.completed",
      "order_id": 1739900000000,
      "external_id": null,
      "data": {
        "final_state": "fulfilled",
        "units_picked": 8,
        "units_requested": 8,
        "duration_ms": 192432
      }
    }
  ]
}
```

**Common event fields:**

| Field                  | Type              | Req | Meaning                                                                                                                  |
| ---------------------- | ----------------- | --- | ------------------------------------------------------------------------------------------------------------------------ |
| `events[].event_id`    | string            | yes | ULID or UUID, unique per event. WMS dedupes on this for 24h (in addition to batch-level `X-Shelfbot-Message-Id`).        |
| `events[].occurred_at` | string            | yes | ISO 8601 UTC timestamp of when the event happened on Shelfbot. May predate the request by hours if Shelfbot was offline. |
| `events[].type`        | enum              | yes | One of the event types listed below.                                                                                     |
| `events[].order_id`    | int               | yes | The `order_id` originally sent by the WMS in `/v1/order/update`.                                                         |
| `events[].external_id` | int\|string\|null | no  | Echoed from the order's `external_id` if the WMS supplied one. Null if not.                                              |
| `events[].data`        | object            | yes | Type-specific payload (see per-type sections).                                                                           |

#### Event types

##### `line.picked`

Emitted when a committed bin presentation completes against a line. The grain of this event is the committed bin presentation, not an individual scan. Most lines need one presentation, so most lines emit exactly one `line.picked` event carrying the full commanded quantity. A line whose stock spans multiple bins emits one event per presentation: each carries the incremental `picked_quantity` with status `"partial"`, and the last carries status `"complete"`. A short pick followed by a top-up from another bin follows the same pattern.

**Multi-event semantics:**

* `picked_quantity` on each event is **incremental**: the units removed in this specific presentation, not the cumulative total.
* The WMS sums `picked_quantity` across events for a given `line_id` to get cumulative progress.
* `status` describes the line's state at the moment this event closes:
  * `"complete"`: this presentation brought the line to fully picked (cumulative sum equals `requested_quantity`). No more `line.picked` events for this line.
  * `"partial"`: this presentation contributed to the line, but the line is not yet fully picked and further `line.picked` events are expected (the line's remaining stock is in another bin, or the operator has routed the order to an issues location for later top-up).
  * `"short"`: this presentation closed the line with less than `requested_quantity`. No more events expected; line is final.
  * `"missing"` — line closed with zero picked (SKU not findable). No more events expected.
  * `"manual"`: line satisfied outside the normal pick flow (operator override at HMI); `picked_quantity` reflects what the operator recorded.

**Un-picks and corrections:** corrections made before a transaction commits never become events; a unit scanned and then returned during a presentation is invisible to the WMS. Post-commit discrepancies surface as `inventory.adjusted` events (reason codes `mis_picked`, `missing`). `order.completed` always carries the final authoritative totals.

**Short-pick with recount:** when a presentation ends with the bin count wrong (Shelfbot expected 4, the operator found 2), the HMI requires the operator to confirm the bin's remaining count before releasing the bin. This generates:

1. A `line.picked` event with the units actually removed and status reflecting line state.
2. A separate `inventory.adjusted` event (*POST /v1/inventory/event*) reporting the count correction, with `reason.code: "missing"`.

The two events may be emitted in the same batch.

| `data` field         | Type      | Req         | Meaning                                                                                                                                                                                                                                              |
| -------------------- | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `line_id`            | int       | yes         | The `line_id` originally sent by the WMS.                                                                                                                                                                                                            |
| `sku`                | string    | yes         | SKU picked (or attempted).                                                                                                                                                                                                                           |
| `requested_quantity` | int       | yes         | Quantity the WMS asked for (current value of `quantity` on the line).                                                                                                                                                                                |
| `picked_quantity`    | int       | yes         | Units picked in **this presentation**. Incremental; the WMS sums across events.                                                                                                                                                                      |
| `status`             | enum      | yes         | See multi-event semantics above.                                                                                                                                                                                                                     |
| `bin_id`             | int       | yes         | The bin presented for this event.                                                                                                                                                                                                                    |
| `shipper_id`         | string    | conditional | Label of the shipper the units were put into (see *Shippers*). Present when `picked_quantity` is greater than zero.                                                            |
| `shipper_seq`        | int       | conditional | Sequence of that shipper within the order. Present with `shipper_id`.                                                                                                          |
| `operator`           | string    | no          | Operator id if a human picked or marked manual; omitted for fully-robotic picks.                                                                                                                                                                     |
| `batches`            | array     | conditional | Required if the SKU is batch-tracked. Breakdown of which batch(es) the units came from in this presentation. Each entry: `{ batch_number, quantity, expires_at, serials?[] }`; `expires_at` is required when the SKU has `requires_expiry: true` and present otherwise whenever it was captured (see *Expiry is always reported*). `serials[]` required when the SKU is both batch- and serial-tracked. |
| `serials`            | string\[] | conditional | Required if the line calls for serial reporting (the SKU is serial-tracked, or the line was sent with `capture_serials: true`) and the SKU is not batch-tracked. Exact serials picked in this presentation (length must equal `picked_quantity`). When both batch and serial are tracked, serials live inside `batches[].serials`.                       |
| `serials_keyed`      | string\[] | no          | Serials the operator keyed rather than scanned, a subset of the serials reported on this event wherever they appear (see *Serials at pick*).                                        |

Note: `bin_id` identifies the bin presented. Compartment structure within a bin remains internal to Shelfbot; the WMS may read bin state via *GET /v1/bins/{bin_id}* but never commands bin placement.

##### `order.completed`

Emitted exactly once per order when it reaches a terminal state.

| `data` field      | Type | Req | Meaning                                                                                                                                                                                                        |
| ----------------- | ---- | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `final_state`     | enum | yes | `"fulfilled"` (every line complete), `"partially_fulfilled"` (at least one short or missing, at least one complete), `"failed"` (no lines picked at all). |
| `units_picked`    | int  | yes | Sum of cumulative `picked_quantity` across all lines.                                                                                                                                                          |
| `units_requested` | int  | yes | Sum of `requested_quantity` across all lines.                                                                                                                                                                  |
| `duration_ms`     | int  | no  | Wall time from order acceptance to completion.                                                                                                                                                                 |

##### `shipper.opened`

Emitted when the runner inducts a shipper for the order: the scan that binds the label to a trolley position and to the order. The order's first shipper opens when the order is first bound to the trolley; a further shipper opens after the previous one closed full.

| `data` field  | Type   | Req | Meaning                                                                      |
| ------------- | ------ | --- | ---------------------------------------------------------------------------- |
| `shipper_id`  | string | yes | The label code exactly as scanned (1 to 64 characters). Opaque to Shelfbot.  |
| `shipper_seq` | int    | yes | 1 for the order's first shipper, 2 for the next, and so on.                  |

##### `shipper.closed`

Emitted when a shipper stops receiving picks for the order: Shelfbot tagged it full and the runner is swapping it out, the order reached a terminal state (its last shipper closes with it), or the WMS cancelled the order. Every shipper that opened closes exactly once, whatever the reason, so the WMS can pair each `shipper.opened` with its `shipper.closed` and always knows when a label is free. `contents` is the shipper's manifest and is final; it is empty when nothing was put into the shipper before it closed.

| `data` field  | Type   | Req | Meaning                                                                                                                                                                                                                                                                  |
| ------------- | ------ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `shipper_id`  | string | yes | The label code, as on `shipper.opened`.                                                                                                                                                                                                                                  |
| `shipper_seq` | int    | yes | As on `shipper.opened`.                                                                                                                                                                                                                                                  |
| `reason`      | enum   | yes | `"full"`, `"order_complete"` or `"order_cancelled"`.                                                                                                                                                                                                                     |
| `contents`    | array  | yes | What is in the shipper: one entry per line with units in it, `{ line_id, sku, quantity, batches?[], serials?[] }`, with `batches` and `serials` shaped and required as on `line.picked`. Empty when nothing was put into the shipper before it closed.                    |

The order is not complete when a shipper closes full: `order.completed` still fires once, when the last line closes. Between the two, the WMS knows which lines are in which shipper and runs its own process, dispatching each shipper as it arrives or holding for the order, as it sees fit. A shipper's label stays bound to the order until the WMS closes the order (see *Shippers*).

**Behaviour (receiver / WMS):**

* Verify auth, dedupe on `X-Shelfbot-Message-Id` (batch) and per-event on `event_id`.
* Apply each event in `events[]` order. Ordering is best-effort — WMS MUST tolerate out-of-order delivery. Use `occurred_at` to reconstruct the true order if needed.
* For `line.picked`, maintain a running sum of `picked_quantity` per `line_id`. Latest `status` wins for line state.
* For `shipper.closed`, treat `contents` as the shipper's final manifest. `shipper.opened` may be ignored by a WMS that only wants manifests.
* Unknown `order_id`: acknowledge the batch with 2xx, log the event, and continue. Never fail a batch over one unrecognised event.

**Acknowledgement:** any HTTP 2xx response acknowledges the whole batch; no response body is required and any body is ignored. See *Acknowledgement*.

### 7.4 POST /v1/inventory/event

**Direction:** Shelfbot → WMS
**Purpose:** Report inventory changes that occur outside the order-pick flow — stock-in, manual count corrections, and short-pick recounts.
**Trigger:** Shelfbot emits whenever physical stock changes for a reason other than fulfilling an order line. (Pick-driven stock decrements are implied by `line.picked` events in *POST /v1/order/event* and are NOT re-emitted here.)

**Request:**

```json
{
  "events": [
    {
      "event_id": "01HZQX6P8M2N4R7K0V3D9Y6BAE",
      "occurred_at": "2026-04-23T11:02:14.000Z",
      "type": "inventory.stocked",
      "data": {
        "sku": "BOLT_M5_10HST_BLACK",
        "delta": 500,
        "new_count": 540,
        "bin_id": 40213,
        "operator": "user-17",
        "reference": "PO-78421",
        "batch": { "batch_number": "L240423", "expires_at": "2027-04-23" },
        "serials": []
      }
    },
    {
      "event_id": "01HZQX6P9N3K8M2D0V6BAEFGHJ",
      "occurred_at": "2026-04-23T11:05:30.000Z",
      "type": "inventory.adjusted",
      "data": {
        "sku": "NUT_M5_NYL",
        "delta": -3,
        "new_count": 217,
        "bin_id": 40214,
        "operator": "user-17",
        "reason": { "code": "missing" },
        "batch_number": "L240301",
        "serials_affected": []
      }
    }
  ]
}
```

**Important semantic note:** `new_count` is Shelfbot's new **total stock of the SKU** across all of its managed storage (or, for batch-tracked SKUs, the new total for the specific batch). It is not a per-tote or per-compartment count. The WMS can use this as a cross-check against its own running total; if they diverge, something is wrong.

#### Event types

##### `inventory.stocked`

A receiving / restocking action — units added to Shelfbot's managed stock from outside (delivery, transfer in).

One event is emitted per bin return: the bin coming back is the committed transaction, and `delta` is the net units added during that presentation.

| `data` field | Type      | Req         | Meaning                                                                                                                                                              |
| ------------ | --------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sku`        | string    | yes         | SKU added.                                                                                                                                                           |
| `delta`      | int       | yes         | Net units added during this presentation. Always positive for `stocked`.                                                                                             |
| `new_count`  | int       | yes         | Shelfbot's new total for this SKU (or batch, for batch-tracked SKUs) after the change.                                                                               |
| `bin_id`     | int       | yes         | The bin the units were stocked into.                                                                                                                                 |
| `operator`   | string    | yes         | Operator id who did the stock-in.                                                                                                                                    |
| `reference`  | string    | no          | Optional reference (purchase order, transfer id, etc.).                                                                                                              |
| `carton_id`  | string    | no          | The carton this stock-in came from, when the operator received by carton scan (see *POST /v1/carton/update*). A per-carton goods-receipt confirmation.               |
| `batch`      | object    | conditional | Required if the SKU is batch-tracked. Shape: `{ batch_number, expires_at?, produced_at?, supplier_batch_ref? }`. `expires_at` required when `requires_expiry: true`. |
| `serials`    | string\[] | conditional | Required if the SKU is serial-tracked. Array of serials, one per unit (length must equal `delta`).                                                                   |

##### `inventory.adjusted`

A stock correction — Shelfbot's count for a SKU (or batch) changed for a reason other than a receipt or a pick. Fires in two scenarios:

1. **Short-pick recount.** Operator found fewer units in a tote than expected; the recount at HMI generates this event alongside the accompanying `line.picked`.
2. **Standalone adjustments.** Cycle counts, damage write-offs, recalls, etc., initiated at a Shelfbot station.

`delta` may be positive (found extra) or negative (missing, damaged, expired, etc.). `delta: 0` is permitted to record that a count was performed and confirmed correct.

| `data` field       | Type      | Req         | Meaning                                                                                                                                                                                                                                                                         |
| ------------------ | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sku`              | string    | yes         | SKU adjusted.                                                                                                                                                                                                                                                                   |
| `delta`            | int       | yes         | Signed difference between new count and previous count.                                                                                                                                                                                                                         |
| `new_count`        | int       | yes         | Shelfbot's new total for this SKU (or batch) after the adjustment.                                                                                                                                                                                                              |
| `bin_id`           | int       | yes         | The bin whose contents were adjusted.                                                                                                                                                                                                                                           |
| `operator`         | string    | yes         | Operator id — required for audit trail.                                                                                                                                                                                                                                         |
| `reason`           | object    | yes         | Structured reason. Shape: `{ code: enum, note?: string }`. See reason codes below. `note` is required when `code` is `"other"`, optional otherwise.                                                                                                                             |
| `batch_number`     | string    | conditional | Required if the SKU is batch-tracked — identifies which batch was adjusted.                                                                                                                                                                                                     |
| `serials_affected` | string\[] | no          | For serial-tracked SKUs: the specific serials added (positive `delta`) or removed (negative `delta`) from stock. If the operator couldn't identify individual serials during a count, omit; Shelfbot will flag unreconciled serials via an `integrity` exception (*POST /v1/exception*). |

**Reason codes:**

| Code                   | Meaning                                                                                                                                                                                |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing`              | Expected units weren't there, no further explanation (routine short-pick correction, cycle count discrepancy).                                                                         |
| `damaged_removed`      | Units were there but unusable; operator removed them from stock.                                                                                                                       |
| `found_extra`          | More units than Shelfbot expected (positive `delta`).                                                                                                                                  |
| `expired`              | Units past expiry date, removed from pickable stock.                                                                                                                                   |
| `quality_hold`         | Units pulled for QA inspection; may return later via `inventory.stocked`.                                                                                                              |
| `cycle_count`          | Routine count reconciliation with no specific cause; use when a discrepancy is found during a scheduled count and the operator doesn't know why.                                       |
| `recall`               | Supplier or regulatory recall; units pulled from pickable stock. Typical for batch-tracked SKUs.                                                                                       |
| `mis_picked`           | Correcting a previous mis-pick (operator took the wrong SKU). Usually paired — one negative adjustment on the SKU that was over-picked, one positive on the SKU that was under-picked. |
| `returned_to_supplier` | Stock sent back to supplier (wrong item, over-shipment).                                                                                                                               |
| `sample_taken`         | Units removed for QA sampling, testing, or customer samples; not returning to stock.                                                                                                   |
| `transfer_out`         | Units moved to another location outside Shelfbot's managed stock.                                                                                                                      |
| `transfer_in`          | Units moved in from another location outside Shelfbot's managed stock.                                                                                                                 |
| `other`                | Free-form reason — `note` field required and must describe the cause.                                                                                                                  |

**Behaviour (receiver / WMS):**

* Verify auth, dedupe on `X-Shelfbot-Message-Id` and per-event on `event_id`.
* Apply each event to the WMS's view of inventory. The WMS holds the authoritative count; `new_count` is informational for cross-check.
* Out-of-order delivery is possible. Use `occurred_at` to sequence; if an older event arrives after a newer one, WMS SHOULD log the late event but NOT regress its count.
* Unknown SKU: acknowledge the batch with 2xx, log, and consider triggering a SKU resync. Never fail a batch over one unrecognised event.

**Acknowledgement:** as for *POST /v1/order/event*.

### 7.5 POST /v1/exception

**Direction:** Shelfbot → WMS
**Purpose:** Report abnormal conditions that fall outside the normal pick / stock / adjust flow — hardware faults, scanner errors, integrity violations, operator overrides. These are informational / audit events; the WMS is NOT expected to take corrective action.
**Trigger:** Shelfbot emits when an unexpected condition occurs that the WMS might want to log, alert on, or surface in dashboards.

**Request:**

```json
{
  "events": [
    {
      "event_id": "01HZQX7R3K9M2N5D0V6BAEFGHJ",
      "occurred_at": "2026-04-23T12:14:05.000Z",
      "type": "robot.fault",
      "severity": "error",
      "context": { "robot_id": "SB-2" },
      "data": {
        "code": "MOTOR_STALL",
        "message": "Y-axis motor stalled at position 3200mm",
        "recoverable": true
      }
    },
    {
      "event_id": "01HZQX7R5N3K8M2D0V6BAEFGHJ",
      "occurred_at": "2026-04-23T12:16:44.000Z",
      "type": "scan.error",
      "severity": "info",
      "context": { "operator": "user-17" },
      "data": {
        "barcode_read": "5099999999999",
        "reason": "unknown_barcode"
      }
    }
  ]
}
```

**Common event fields:**

| Field                  | Type   | Req | Meaning                                                                                                                                                                           |
| ---------------------- | ------ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `events[].event_id`    | string | yes | ULID or UUID. Per-event dedupe.                                                                                                                                                   |
| `events[].occurred_at` | string | yes | ISO 8601 UTC.                                                                                                                                                                     |
| `events[].type`        | enum   | yes | One of the exception types below.                                                                                                                                                 |
| `events[].severity`    | enum   | yes | `"info"`, `"warning"`, `"error"`, `"critical"`. Lets the WMS route to appropriate dashboards / alerting.                                                                          |
| `events[].context`     | object | no  | Scoping info. Allowed fields: `robot_id`, `sku`, `order_id`, `line_id`, `operator`. Shelfbot-internal identifiers (tote ids, compartment indices, station ids) are never exposed. |
| `events[].data`        | object | yes | Type-specific payload.                                                                                                                                                            |

#### Exception types

##### `robot.fault`

Hardware or firmware fault on a Shelfbot robot.

| `data` field  | Type   | Req | Meaning                                                                                                                                        |
| ------------- | ------ | --- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`        | string | yes | Short machine-readable code, e.g. `"MOTOR_STALL"`, `"LIMIT_SWITCH"`, `"COMMS_LOST"`, `"ESTOP_TRIGGERED"`, `"THERMAL"`. Deployments may extend. |
| `message`     | string | yes | Human-readable description.                                                                                                                    |
| `recoverable` | bool   | yes | true if Shelfbot expects to auto-recover; false if it needs maintenance.                                                                       |

##### `scan.error`

A scan could not be resolved or was invalid. Not all scan errors emit events — only those the WMS might care about (e.g. repeated unknown barcodes may indicate a SKU sync issue).

| `data` field   | Type   | Req | Meaning                                                      |
| -------------- | ------ | --- | ------------------------------------------------------------ |
| `barcode_read` | string | yes | The raw barcode value that was scanned.                      |
| `reason`       | enum   | yes | `"unknown_barcode"`, `"damaged_barcode"`, `"wrong_context"`. |

##### `batch.mismatch`

The WMS supplied a shelf-life date for a batch on an order line (*POST /v1/order/update*) that disagrees with the expiry Shelfbot recorded for that batch at induct. The order is accepted and picked by batch number; this exception tells both sides to reconcile their batch data.

| `data` field           | Type   | Req | Meaning                                    |
| ---------------------- | ------ | --- | ------------------------------------------ |
| `order_id`             | int    | yes | The order carrying the line.               |
| `line_id`              | int    | yes | The line.                                  |
| `sku`                  | string | yes | The SKU.                                   |
| `batch_number`         | string | yes | The batch in question.                     |
| `wms_expires_at`       | string | yes | The date the WMS supplied.                 |
| `shelfbot_expires_at`  | string | yes | The date Shelfbot holds from induct.       |

##### `integrity`

Shelfbot detected an internal consistency issue — count went negative, two batches found in one compartment, orphaned serial, etc. These indicate a bug or data corruption; the WMS SHOULD alert loudly.

| `data` field     | Type   | Req | Meaning                                                                                                                                       |
| ---------------- | ------ | --- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`           | string | yes | e.g. `"NEGATIVE_COUNT"`, `"MIXED_BATCH_COMPARTMENT"`, `"ORPHAN_SERIAL"`, `"COMPARTMENT_OVERFILL"`.                                                        |
| `message`        | string | yes | Human-readable description.                                                                                                                   |
| `auto_corrected` | bool   | yes | true if Shelfbot made a safe correction (e.g. clamped count to 0); false if the system is left in an inconsistent state pending human review. |

##### `override`

An operator bypassed a Shelfbot refusal (typically after an HMI alert). Always emit so the WMS has an audit record of who overrode what.

| `data` field  | Type   | Req | Meaning                                                                                                 |
| ------------- | ------ | --- | ------------------------------------------------------------------------------------------------------- |
| `override_of` | string | yes | The error code that was overridden (e.g. `"BATCH_MISMATCH"`, `"SERIAL_COLLISION"`, `"COUNT_MISMATCH"`). |
| `operator`    | string | yes | Operator id who authorised the override.                                                                |
| `reason`      | string | yes | Free-form reason supplied by the operator.                                                              |
| `auth_level`  | enum   | no  | `"operator"`, `"supervisor"`, `"admin"` — if the deployment uses tiered authorisation.                  |

**Behaviour (receiver / WMS):**

* Verify auth, dedupe on `X-Shelfbot-Message-Id` (batch) and per-event on `event_id`.
* Log / store each event. Route by severity to appropriate channels (info → audit log; warning → dashboard; error/critical → alerting).
* No corrective action is expected from the WMS.
* Unknown type: acknowledge the batch with 2xx and log the unknown type. Ignoring exception types the WMS does not process is always safe and allows Shelfbot to add new exception types without breaking older WMSes.

**Acknowledgement:** as for *POST /v1/order/event*.

## 8. Notes & Responsibilities

### 8.1 WMS Integrator responsibilities

* **Credential custody** — safeguarding the HMAC shared secret. Never committing secrets to version control.
* **SKU sync** — keeping Shelfbot's SKU catalogue in sync with the WMS master data.
* **Order lifecycle** — creating orders via `/v1/order/update` with `state: "open"`, and closing every order with `fulfilled` or `cancelled` once the WMS has finished with it. A shipper label stays bound to its order until then, so an order left open holds its shippers (see *Shippers*).
* **Batch status:** blocking a recalled or held batch through `/v1/batch/update` as soon as the WMS blocks it, and releasing it when the hold ends. Shelfbot cannot see the WMS's batch status and will otherwise keep offering the batch.
* **Event receipt** — hosting the Shelfbot → WMS endpoints (*Endpoints — Shelfbot → WMS*) with correct auth verification, idempotency handling, and tolerance for out-of-order delivery.
* **Cumulative pick tracking** — summing `picked_quantity` across `line.picked` events per `line_id` to derive cumulative progress.
* **Inventory reconciliation** — treating inbound `inventory.stocked` and `inventory.adjusted` events as authoritative signals that the WMS's own stock count needs to move by `delta`. Using `new_count` as a cross-check.
* **Failure handling** — honouring the retry policy in *Retry policy (sender)*.
* **Audit retention** — retaining event records for the customer's regulatory environment.

### 8.2 Shelfbot responsibilities

* **Physical-state integrity** — keeping tote-level inventory consistent with physical reality. Refusing any request that would violate a documented invariant.
* **HMI operator flows** — surfacing invariant violations at the station and halting local flow until resolved. Forcing tote recount after short picks.
* **Scanning** — owning all barcode scanning at stations; validating scans against current tote/order state before emitting events.
* **Event emission** — emitting `/v1/order/event`, `/v1/inventory/event`, and `/v1/exception` events as the authoritative record of what happened on the picking floor.
* **Shipper assignment:** deciding which shipper each order is picked into and when a shipper is full, and reporting it through the shipper events and reads.
* **Honouring batch status:** excluding blocked and expired batches from picking and refusing them at induct, and serving the batch lookup so a recall can be located and reconciled.
* **Operational telemetry:** Shelfbot retains operational telemetry and performance data for monitoring, support, and reporting, surfaced through Shelfbot's own dashboards. The WMS is not required or expected to derive Shelfbot equipment KPIs from transactional events; `duration_ms` on `order.completed` remains available for WMSes that want it.
* **Auth enforcement** — verifying HMAC signatures, timestamps, and (if configured) IP allowlists.
* **Backward compatibility** — within a major version, preserving existing field semantics and only introducing additive changes.

### 8.3 Shared responsibilities

Before go-live, both parties must agree on:

* HMAC key exchange (Shelfbot issues, WMS stores securely).
* Webhook base URLs for each direction.
* Optional IP allowlist contents.
* Expected batch/serial tracking per SKU class, any `requires_expiry` policy, the `batch_policy` the WMS sends by default, and how cartons bound for Shelfbot carry batch and expiry (GS1 content barcodes, carton records, or keyed at induct).
* Retention period for events and audit records on each side.

Any deviation from this specification must be agreed in writing between the WMS operator and Shelfbot prior to deployment.

***

## 9. Revision History

| Version | Date       | Change |
| :-----: | :--------- | :----- |
|   3.1   | 19/08/2026 | Published to Web Site |
|   3.2   | 01/09/2026 | Batch size errors return 400; webhook acknowledgement simplified to any 2xx; transaction-grain event semantics with bin_id; read endpoints for SKUs, orders, and bins; catalogue summary for reconciliation; integration profiles; carton feed (SSCC) for receiving |
|   3.3   | 10/09/2026 | Order priority flag: optional `priority` on order create and update, honoured in pick-queue sequencing and echoed by the order read; line-level `capture_serials`: an order line can require the picked serial numbers to be scanned and reported back to the WMS; shippers: `shipper.opened` and `shipper.closed` events carrying the shipper manifest, shipper on `line.picked`, shippers on the order read and a lookup by label; the WMS closes every order with `fulfilled` or `cancelled` to release its shipper labels; the never-emitted `order.cancelled` event removed: cancellation is the WMS's alone; batch handling aligned to receipt-led practice: `batch_policy` (preferred with strategy fallback, or required), a shelf-life date and `min_expires_at` on the line, `fefo` pick strategy (default for expiry-tracked SKUs), batch established at induct with the product scanned at pick, `batch_scan` defined, expiry always reported for expiry-tracked SKUs, expired stock rule, keyed serials marked, `batch.mismatch` exception; recall loop: `POST /v1/batch/update` blocks or releases a batch (excluded from picking including open lines, refused at induct, pulled only by a required-batch order), `GET /v1/batches` locates a batch with on hand per bin, batch status on the bin read |
