Inventory movements
The append-only movement model, writing a movement, the movement event, and why absolute writes are forbidden.
On this page
This page is for anyone syncing stock or recording damage and returns. Inventory in Dukkan is an append-only ledger of movements; there is no stock number you write, only movements you add, from which the server computes the balance.
The model#
{
"id": "0a1b2c3d-0001-4e8f-9b70-2d1c3e4f5a6b",
"sequence": 418,
"variant_id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
"location_id": "10c00000-0001-4e8f-9b70-2d1c3e4f5a6b",
"delta": -2,
"stock_after": 23,
"reason": "damage",
"occurred_at": "2026-08-18T16:10:00.000Z"
}| Field | Meaning |
|---|---|
sequence |
per-store monotonic counter; order movements by it |
variant_id |
the variant; may be null for movements tied to a deleted variant |
location_id |
the location (every store has a default location and possibly warehouses) |
delta |
the signed change; never zero |
stock_after |
the balance at this location after the movement, as computed by the server |
reason |
why the movement happened |
occurred_at |
RFC 3339 timestamp |
Reasons an app may write: correction, initial, return and damage. Reads also show reasons the platform writes (sales, cancellations, transfers); treat reason as an open string when reading.
Why absolute writes are forbidden#
Writing "stock = 40" erases whatever happened between your read and your write: a storefront sale, a POS sale, another app. A movement of delta: +5 with reason correction composes safely with everything before it and leaves a trail the merchant can audit. That is why the API never accepts an absolute number.
Write a movement#
Requires inventory:write and an Idempotency-Key:
curl -X POST https://dukkan.one/platform-api/v1/inventory/movements \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: damage-SHIPMENT_ID-1" \
-H "Content-Type: application/json" \
-d @movement.json{
"variant_id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
"location_id": "10c00000-0001-4e8f-9b70-2d1c3e4f5a6b",
"delta": -2,
"reason": "damage",
"note": "Broken in transit"
}deltais between −1,000,000 and 1,000,000 and never zero.reasonis one ofcorrection,initial,return,damage.noteis optional, up to 500 characters.- The
201response carries the recorded movement;stock_afterappears in it only when the install also holdsinventory:read. - A movement that would take available stock below the reserved quantity returns
409, as does a movement on a product without inventory tracking.
Read movements#
curl "https://dukkan.one/platform-api/v1/inventory/movements?limit=100" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Requires inventory:read. The list pages with an opaque cursor; for incremental sync keep the last sequence you processed and skip anything at or below it.
The inventory.movement_created event#
Delivered for every movement, from any source, to subscriptions holding inventory:read:
{
"movement_id": "0a1b2c3d-0001-4e8f-9b70-2d1c3e4f5a6b",
"variant_id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
"location_id": "10c00000-0001-4e8f-9b70-2d1c3e4f5a6b",
"delta": -2,
"stock_after": 23,
"reason": "damage"
}The event carries stock_after directly, so you need no extra call for the balance at that location. Order events by the envelope sequence as described in Webhooks.
Restock on refund#
When you refund specific lines, you can return the quantities to stock in the same operation with the restock: { "location_id": "…" } option of POST /orders/{id}/refunds. It requires lines in the refund and the inventory:write scope; products without inventory tracking are skipped silently. Details in Money in minor units.