Feeef Cloud
Inventory API

Events & webhooks

The inventory webhook contract, and topic patterns for subscribing to a slice of the namespace/key hierarchy.

Events

EventFires when
inventory.stock.changedan operation posts or is reversed — on-hand moved
inventory.operation.postedany operation lands in the ledger (audit/sync consumers)
inventory.reservation.expiredan active reservation passed its expiresAt and was released

Events are cache and reconciliation signals — never the write path. Delivery is at-least-once and HMAC-signed; verify the signature before trusting a body.

One inventory.stock.changed fires per posted (or reversed) operation, not per affected object. A transfer touching two namespaces is a single event carrying both legs:

{
  "id": "evt_01HXYZ…",
  "type": "inventory.stock.changed",
  "apiVersion": "2026-08-17",
  "createdAt": "2026-08-20T12:00:00.000Z",
  "payload": {
    "operationId": 381,
    "movements": [
      { "objectId": 8110, "namespace": "wh-main", "key": "tshirt/Red/XL", "batch": "",
        "quantityName": "on_hand", "delta": -20, "balanceAfter": 120 },
      { "objectId": 8140, "namespace": "wh-oran", "key": "tshirt/Red/XL", "batch": "",
        "quantityName": "on_hand", "delta": 20, "balanceAfter": 20 }
    ]
  }
}

Each movement carries its own namespace/key/batch, so a consumer never has to resolve objectId back to an object to know what moved.

Topic patterns

By default an endpoint subscribed to inventory.stock.changed receives every stock change on the project. An optional topicPattern on the endpoint narrows that to a slice of the hierarchy, so a warehouse-specific consumer isn't woken by every unrelated SKU.

The pattern is matched against the object's namespace segments followed by its key segments — both are already /-hierarchical, so they form one path. Syntax is MQTT/NATS-style:

TokenMatches
literalthat exact segment
+exactly one segment
+nameexactly one segment, captured as name
#every remaining segment — must be the last token
@globrestricts the batch (*/?); omitted or @* means any batch
wh-main/+zone/tshirt/+color/XL   → tshirt/Red/XL in any zone under wh-main
wh-main/tshirt/#                 → everything under the tshirt key in wh-main
wh-oran/#                        → an entire namespace, any key, any batch
wh-main/tshirt@LOT-*             → only batches beginning LOT-

Wildcards occupy a whole segment — tsh+rt and wh-*/tshirt are rejected at validation time, not silently treated as literals. That restriction is what keeps matching independent of how deep the hierarchy goes.

Naming a wildcard (+color rather than +) documents intent and shows the captured value in the playground, but it does not change what is delivered: one event body is built per event and shared by every recipient, so captures are not injected into your payload. Read the values you need from movements[] — each entry already carries its full namespace/key/batch.

A multi-leg operation is delivered if any one of its movements matches, so the transfer above reaches an endpoint scoped to wh-oran/# even though its first leg is in wh-main. Events with no location data — inventory.operation.posted, inventory.reservation.expired — never match a pattern-scoped endpoint, since there is nothing in them to test it against.

Leave topicPattern unset (null) to receive every event the endpoint is subscribed to.

Trying a pattern

The console's Inventory → Webhook playground checks a pattern against a sample namespace/key/batch and shows the captures it produces, before you save it onto a real endpoint. Patterns are also validated on write — an uncompilable pattern is rejected with 400 validation_failed rather than stored and silently never matching.

On this page