Events & webhooks
The inventory webhook contract, and topic patterns for subscribing to a slice of the namespace/key hierarchy.
Events
| Event | Fires when |
|---|---|
inventory.stock.changed | an operation posts or is reversed — on-hand moved |
inventory.operation.posted | any operation lands in the ledger (audit/sync consumers) |
inventory.reservation.expired | an 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:
| Token | Matches |
|---|---|
literal | that exact segment |
+ | exactly one segment |
+name | exactly one segment, captured as name |
# | every remaining segment — must be the last token |
@glob | restricts 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.