Running a stocktake
A stocktake (an inventory in the API surface) is Loca.fi's audited count of a place: configure how variance is judged, create the count, upload scan batches, resolve, and — depending on your settings — pass through a validation gate and a manager approval gate before the platform corrects item states and locations. This guide walks the full loop end to end, including the blind-count handoff between a back-office creator and a scanning device, and the lightweight cycle-count alternative.
Read Stocktakes & counting first for the state machine and the inventory-vs-cycle-count split — this guide is the doing, that page is the understanding.
Overview
The happy path is six moves:
- Configure validation and approval once —
setorganisationinventorysettings(and per-place overrides). - Create the stocktake —
createinventory(stateCreated). - Scan — upload one or more scan batches with
addinventorysnapshot(stateInProgress). - Finish — reviewed
(
resolveinventory) or unattended (resolveinventorywithoutinput, or the one-shotaddsnapshotandcomplete). - Fork on outcome —
Complete,ValidationFailed, orPendingApproval; parked counts need a place manager'smanageinventorydecision. - Report — variance via
getinventoryvalidationrecords/{id}, audit viagetinventorytransactions/{id}.
Every inventory action is gated on the Inventory module permission
(settings endpoints accept Administration / Management / Organisation-or-Place
instead — any one suffices).
Prerequisites
- A token with Inventory permissions — see Getting started.
- A place to count (not a system place) — see Places & visibility.
- Items/tags at that place. Unknown tags are handled — the scan upload auto-creates items for them — but a meaningful variance report needs an expected baseline (stock on hand, a previous stocktake, or external counts).
- For the approval flow: the approving user must be a place manager of the counted place.
Step-by-step
1. Configure validation and approval
Settings drive what the resolve engine does with the count. Set them organisation-wide, then override per place where needed:
curl -sk -X POST "http://<host>/api/inventorysettings/setorganisationinventorysettings" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"EnforceInventoryValidation":true,
"ValidationSource":"CurrentStock",
"ValidationType":"SkuValidation",
"ApprovalType":"Manual",
"AllowedVariance":5.0}'
| Field | Effect |
|---|---|
ValidationSource | Where the expected baseline comes from: CurrentStock (items currently in stock), LastInventoryCount (previous completed stocktake), or ExternalCount (ERP-fed counts, honouring per-SKU variance overrides). Unset means scanned-only rows, never gated. |
ValidationType | SkuValidation — every gated per-SKU row must pass; TotalValidation — a single aggregate row (SkuId: null) solely decides pass/fail. |
EnforceInventoryValidation | true makes variance rows gating (fail → ValidationFailed); false still produces the rows, purely informational. |
ApprovalType | Auto completes a passing resolve immediately; Manual parks it at PendingApproval for a place manager. |
AllowedVariance | Default tolerated variance percentage per gated row. |
Per-place overrides go to
setplaceinventorysettings
with a PlaceId plus OverrideGlobalSettings: true — without that flag
the resolve engine ignores the place row and falls back to the organisation
row. Always supply PlaceId on the place endpoint (omitting it saves the
payload as the organisation row, then fails the read-back with a 500). Read
back with
getorganisationinventorysettings /
getplaceinventorysettings/{placeId} —
the place read returns the raw place row without applying the fallback.
2. Create the stocktake
curl -sk -X POST "http://<host>/api/inventories/createinventory" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Store 1 EOFY stocktake","PlaceId":"<placeId>",
"StocktakeScope":"Full","IsBlind":true}'
Returns 200 with an InventoryDetailDto (InventoryState: "Created") —
capture the Id for every later call.
Three optional fields pick the subtype, which restricts the expected baseline at resolve time:
- Nothing extra → Standard (full-place count).
SkuGroupId→ SKU Group (count judged against one SKU group).SkuIds(non-empty list) → Custom SKU (an ad-hoc SKU list).InventoryType: "Consolidated Inventory"→ the multi-zone roll-up (see below) — the explicitInventoryTypestring ("Standard","SKU Group","Custom SKU","Consolidated Inventory"— spaced wire values) is the only way to create one.
Supplying SkuGroupId and SkuIds together is rejected. Open-count
uniqueness is enforced per place: one open Standard count, one open SKU Group
count per group, unlimited Custom SKU counts — and a Consolidated count
requires no other open count at all. A violation is 400 InvalidOperation
with the descriptive text in Field, not Message.
StocktakeScope and IsBlind are reporting metadata, covered in
Blind counts & StocktakeScope below — they do
not change what the API counts.
3. Upload scans (repeat as needed)
Each scan batch from the handheld/reader becomes an immutable snapshot at the inventory's place. Upload as many batches as the count needs:
curl -sk -X POST "http://<host>/api/inventories/addinventorysnapshot" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"InventoryId":"<inventoryId>",
"SnapshotType":"Add",
"StartTime":"2026-07-07T09:00:00Z","EndTime":"2026-07-07T09:12:30Z",
"Tags":[
{"TagNumber":"3034257BF7194E4000001A85","TagType":"PassiveRfid",
"ReadCount":12,"Rssi":-52.5,"LastReadTime":"2026-07-07T09:11:58Z"}]}'
Returns 200 with an InventoryOperationResultDto (always this shape on this
endpoint): InventoryDto is the refreshed detail (now InProgress) and
Notifications lists items whose tags were scanned but excluded because
their current item state disallows counting — items mid-order (Allocated,
Reserved, Dispatched, OnLoan, Consumed). Surface these to the operator;
they are not an error.
Worth knowing:
- Tags are deduped by
TagNumber(latestLastReadTimewins); empty tag numbers are dropped. - Unknown tags auto-create items at the counted place (in-scope SKUs only for SkuGroup/CustomSku subtypes).
SnapshotType: "Remove"removes the submitted tags from the count — the undo for a mis-scanned batch.- Concurrent mutations on the same inventory are serialised by a per-inventory
lock; a busy lock is refused with 400
InvalidOperation— retry.
4. Finish the count — three ways
Reviewed — the operator confirms each item's disposition. Fetch the live
lists from getinventory/{id}, let the
operator review, then post the three lists back:
curl -sk -X POST "http://<host>/api/inventories/resolveinventory" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Id":"<inventoryId>","ReturnResultDto":true,
"FoundItemsExpected":[{"Id":"<itemId1>"},{"Id":"<itemId2>"}],
"FoundItemsUnexpected":[{"Id":"<itemId3>","ReasonId":"<reasonId>"}],
"MissingItems":[{"Id":"<itemId4>","ReasonId":"<reasonId>"}]}'
The list an item appears in decides its outcome — FoundItemsExpected →
Present, FoundItemsUnexpected → Moved (physically relocated here),
MissingItems → Missing. Any ItemState you send per item is ignored.
ReasonId must reference a Reason with Scope: "Item". Each list's count
must reconcile with the server's own computation within ±5%, and duplicate
item ids within a list are rejected — both fail with
FailedToResolveInventory telling you to re-fetch the server's lists.
Unattended — trust the scans; the server derives everything itself, no reconciliation check, no per-item reasons:
curl -sk -X POST "http://<host>/api/inventories/resolveinventorywithoutinput" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"InventoryId":"<inventoryId>","ReturnResultDto":true}'
One-shot —
addsnapshotandcomplete
takes the same body as the scan upload and immediately runs the unattended
resolve: a single call for single-scan counts. Its response never carries the
Notifications list, so prefer the two-step flow when excluded items matter.
With ReturnResultDto: true the response is InventoryOperationResultDto;
false (the default) returns the legacy bare InventoryDetailDto.
5. Fork on the outcome
The resolved InventoryState is one of three:
| State | Meaning | Next move |
|---|---|---|
Complete | Validation passed (or was ungated) and ApprovalType is Auto. Item states and places are corrected now: found → Present, unscanned expected → Missing, foreign items → moved here + Moved. | Done — read the reports. |
ValidationFailed | A gated variance check failed. No item changes applied; parked. | Manager approves/rejects — or add more scans and re-resolve. |
PendingApproval | Validation passed but ApprovalType is Manual. No item changes yet. | Manager approves/rejects. |
When parked, getinventory/{id} populates CurrentUserIsPlaceManager — use
it to show or hide the approve/reject UI. Parked counts stay re-resolvable:
only Complete blocks further scans and resolves.
6. Read the variance report
curl -sk "http://<host>/api/inventories/getinventoryvalidationrecords/<inventoryId>" \
-H "Authorization: Token $TOKEN"
Returns InventoryValidationRecordDto rows — per SKU: ExpectedQty,
ScannedQty, Variance (signed %, round((scanned/expected − 1) × 100, 2)),
tri-state IsValid (null when the row wasn't gated — check
IsValidationGated), and UnitCostAtCount for valuing the variance. A
SkuId: null row is the TotalValidation aggregate. Rows are deleted and
rebuilt on every resolve — export before re-counting if you need the old
report. Cross-stocktake shrinkage reporting uses the OData variant,
getfilteredinventoryvalidationrecords.
7. Manager approve or reject
Only a place manager of the counted place may decide (agents always fail
the check), and only from PendingApproval / ValidationFailed:
curl -sk -X POST "http://<host>/api/inventories/manageinventory" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"InventoryId":"<inventoryId>","ApprovalState":0,
"Comment":"Variance investigated - denim shrinkage confirmed."}'
ApprovalState is int-serialized (unlike most enums in this domain):
0 = Approved, 1 = Rejected. Approve runs the full item cascade and moves
the count to Complete; reject moves it to Rejected (terminal, no item
changes ever applied). The comment lands on the audit row.
8. Audit trail
curl -sk "http://<host>/api/inventories/getinventorytransactions/<inventoryId>" \
-H "Authorization: Token $TOKEN"
One InventoryTransactionSummaryDto per lifecycle step (Created,
SnapshotAdded, PendingApproval, Approved, Rejected, Completed — and
the persisted-typo literal ValidatonFailed), newest first, with the
acting user/agent and comments. On an approve, the Approved row is backdated
one second before Completed so they sort correctly.
Running it asynchronously
Large counts can take minutes to resolve. Every mutating call in steps 3–7
accepts NotifyOnceComplete: true plus NotificationRecipientId set to
your own user/agent id — the work is queued, the response returns
immediately with a BackgroundTaskId and a placeholder body
(InventoryState: "Processing" — synthetic, not read from the database), and
a completion notification is delivered when done. A recipient id that isn't
yours silently falls back to synchronous processing (by design). The polling
and subscription mechanics are on
Asynchronous operations.
Blind counts & StocktakeScope
IsBlind and StocktakeScope on the create request are stored metadata:
the API round-trips them on the summary/detail DTOs but no server behaviour
branches on them today. Scope filtering of what gets counted is driven by
the subtype (SkuGroupId/SkuIds), never by StocktakeScope. The metadata
exists so clients and reporting can agree on the count's purpose:
IsBlind: true— the operator UI must hide expected quantities and live variance during scanning (audit-grade, compliance, and loss-prevention counts). Enforcing that is the client's job — the API still returnsExpectedQtyand variance to anyone with Read permission.StocktakeScope—Full(annual/EOFY whole-place),Cycle(rolling program slice),Spot(targeted SKU subset),HotZone(high-shrink area sweep). Defaults per subtype: Standard/Consolidated →Full, SkuGroup / CustomSku →Spot(cycle counts default toCycle). An incompatible value (e.g.Fullon a SkuGroup count) is silently normalised to the subtype default — no 400 — so send the right value rather than relying on the server.
The canonical blind handoff — created in the back office, counted on a device:
- Back office creates with
IsBlind: true(step 2) and hands over the id. - Device fetches
getinventory/{id}on first open, readsIsBlindandStocktakeScope, and caches them for the whole count (they are fixed at create — no update endpoint accepts them). Blind → show only what has been scanned, never expected counts or variance. - The device runs the normal endpoints —
addinventorysnapshot, thenresolveinventorywithoutinputoraddsnapshotandcomplete. Nothing about the wire flow changes when blind. - After resolve, the variance report is visible to reviewers regardless of
IsBlind— blindness gates the operator during scanning, not the audit.
Prefer a cycle count for routine zone checks
A cycle count is the lightweight sibling: no settings, no validation gate, no approval, and one call does the whole count — the resolve is the scan upload:
curl -sk -X POST "http://<host>/api/cyclecounts/createcyclecount" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"PlaceId":"<placeId>","StocktakeScope":"Cycle"}'
curl -sk -X POST "http://<host>/api/cyclecounts/resolvecyclecount" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"CycleCountId":"<cycleCountId>","SnapshotType":"Add",
"StartTime":"2026-07-07T09:00:00Z","EndTime":"2026-07-07T09:05:00Z",
"Tags":[{"TagNumber":"3034257BF7194E4000001A85","TagType":"PassiveRfid",
"ReadCount":3,"Rssi":-58.0,"LastReadTime":"2026-07-07T09:04:41Z"}]}'
The result (getcyclecount/{id})
labels items Present / Moved / Created (unknown tags auto-created for
in-scope SKUs). The deliberate contrasts with an inventory: no missing
sweep — a cycle count never marks anything Missing — and no
LastStocktakeOn stamp on items; both remain the inventory's job. There is
also no open-count uniqueness check, so concurrent cycle counts per place are
fine. One sharp edge: resolvecyclecount returns 200 even when resolution
partially fails — verify via the count's results, not the status code (see
the 200-despite-failure family).
Consolidated multi-zone stocktakes
To stocktake a whole site zone by zone: run and complete a normal inventory on
each child place, then create one on the parent place with
"InventoryType": "Consolidated Inventory" and resolve it. The consolidated
resolve does not count anything itself — it rolls up each descendant place's
latest Complete inventory (newest count per item wins), then walks the same
validation and approval gates as any other stocktake. It never moves items or
marks Missing. Creation requires no other open inventory on the parent
place.
Retail stock queries after the count
Two read models answer "what's in stock?" afterwards:
GET retail/v1/stock/getcurrentstock— live per-SKU counts from the materialised stock table (maintained in the same transaction as inventory completion). SupportsplaceIdorplaceName, sub-location roll-ups (includeSubLocations/countSubLocations),barcode/skuNofilters, and afieldslist to enrich records with SKU dimensions.GET retail/v1/stock/getlateststocktake— counts as at the latest completed stocktake for the place, whereAvailable = Total − Missing − Reserved. A place with no completed stocktake returns 200 with anullbody.
curl -sk "http://<host>/retail/v1/stock/getcurrentstock?placeName=Store%201&fields=Colour,Size" \
-H "Authorization: Token $TOKEN"
The retail/v1/stock endpoints are the retail-facing read model, and variance
valuation uses UnitCostAtCount — a snapshot of the SKU's supplier cost
(SkuMaster.UnitCost) at count time. For retail tenants all pricing lives on
the SKU; the per-item UnitCost field is asset-management-only (individual
asset cost/depreciation) and plays no part in stocktake variance or retail
stock reporting. Asset-tracking tenants typically care more about the per-item
Present/Missing/Moved outcomes than SKU-level variance percentages.
Error handling
All failures follow the platform-wide
error and response conventions —
400 with a JSON array of { ErrorCode, Message, Field }.
| Situation | Response |
|---|---|
| Duplicate open inventory on the place (per subtype rules) | 400 InvalidOperation — descriptive text in Field, not Message |
SkuGroupId and SkuIds supplied together | 400 InvalidOperation |
InventoryType: "SKU Group" without a SkuGroupId | 400 MissingRequiredField, field "SkuGroupId" |
Unparseable StocktakeScope / InventoryType string | 400 validation error |
Scan/resolve gate refused — lock busy, unknown id, or already Complete | 400 InvalidOperation |
resolveinventory lists off by more than ±5%, or duplicate item ids in a list | 400 FailedToResolveInventory — re-fetch the server's lists and retry |
ReasonId whose Reason is not Scope: "Item" | 400 InvalidReference |
manageinventory by a non-place-manager (agents always) | 400 InvalidOperation ("PlaceManager") |
manageinventory on a count not PendingApproval/ValidationFailed | 400 InvalidOperation ("InventoryState") |
deleteinventory on a Complete count | 400 InvalidOperation (Rejected counts remain deletable) |
| Background scheduling failure on the async path | 400 OperationFailed |
Stock endpoints without placeId or placeName | 400 InvalidValue |
setplaceinventorysettings without PlaceId | 500 after the write already saved to the org row — always supply PlaceId |
No token / wrong scheme (Bearer) | 401 |
| Token lacks the Inventory module permission for the verb | 403 |
Related
- Stocktakes & counting — the state machine, the validation engine, and snapshot eligibility rules behind this guide.
- Asynchronous operations — the
NotifyOnceCompletebackground pattern. - Item lifecycle — what
Present/Missing/Moveddo to items. - Places & visibility — place hierarchy and the sub-location roll-ups used by the stock queries.
- Reference pages: Create Inventory, Add Inventory Snapshot, Add Snapshot & Complete, Resolve Inventory, Resolve Without Input, Manage Inventory, Get Inventory, List Inventories, Validation Records, Transactions, Snapshots, Create Cycle Count, Resolve Cycle Count, Current Stock, Latest Stocktake.