Events
Stock Count publishes two public events. Both are available to external systems through External Events.
| Event | Published when | Use it for |
|---|---|---|
stc.public.event.stock-count-completed.v1 | a count is submitted | the counted quantities — applying a count to your own stock, loss and shrinkage reporting |
stc.public.event.stock-count-changed.v1 | a count is created or edited | planning views, reminders, knowing a count is coming |
A test count is never published on either.
Stock count completed
stc.public.event.stock-count-completed.v1
One submitted count, with every item it covers — counted by staff, or set to 0 because it was mandatory and nobody counted it. Items that were optional and not counted are not in it.
{
"tenantId": "acme",
"businessUnitId": "store-42",
"stockCountId": "0b8e4f6a-2f0c-4a57-9d61-6d1f6f2b7c10",
"type": "CYCLE",
"submittedAt": "2026-09-23T15:02:11.000Z",
"submittedBy": "a5f460606be52a0b91f57c4b2e5c037d",
"comment": "Dairy, week 39",
"numberOfItemsCounted": 2,
"numberOfItemsNotCounted": 0,
"messageNumber": 1,
"lastMessageNumber": 1,
"expectedCostAmount": 118.5,
"actualCostAmount": 94.8,
"firstCountedDateTime": "2026-09-23T12:00:04.512Z",
"lastCountedDateTime": "2026-09-23T15:02:10.998Z",
"items": [
{
"itemId": "7350053850019",
"itemIdentifier": "7350053850019",
"itemName": "Yoghurt natural 1 l",
"expectedQuantity": 25,
"expectedCostAmount": 98.75,
"actualQuantity": 24,
"actualCostAmount": 94.8,
"unitCostPrice": 3.95,
"firstCountedDateTime": "2026-09-23T12:00:04.512Z",
"lastCountedDateTime": "2026-09-23T12:10:31.207Z",
"locations": [
{
"location": "1",
"actualQuantity": 10,
"firstCountedDateTime": "2026-09-23T12:00:04.512Z",
"lastCountedDateTime": "2026-09-23T12:00:04.512Z",
"lines": [
{ "lineId": "7c1e…", "actualQuantity": 10, "unitCostPrice": 3.95, "countedDateTime": "2026-09-23T12:00:04.512Z" }
]
},
{
"location": "2",
"actualQuantity": 14,
"firstCountedDateTime": "2026-09-23T12:10:31.207Z",
"lastCountedDateTime": "2026-09-23T12:10:31.207Z",
"lines": [
{ "lineId": "d402…", "actualQuantity": 14, "unitCostPrice": 3.95, "countedDateTime": "2026-09-23T12:10:31.207Z" }
]
}
]
},
{
"itemId": "7350053850026",
"itemIdentifier": "7350053850026",
"itemName": "Yoghurt vanilla 1 l",
"expectedQuantity": 5,
"expectedCostAmount": 19.75,
"actualQuantity": 0,
"actualCostAmount": 0,
"unitCostPrice": 3.95,
"firstCountedDateTime": "2026-09-23T15:02:10.998Z",
"lastCountedDateTime": "2026-09-23T15:02:10.998Z",
"locations": [
{
"location": "UNKNOWN",
"actualQuantity": 0,
"firstCountedDateTime": "2026-09-23T15:02:10.998Z",
"lastCountedDateTime": "2026-09-23T15:02:10.998Z",
"lines": [
{ "lineId": "91aa…", "actualQuantity": 0, "unitCostPrice": 3.95, "countedDateTime": "2026-09-23T15:02:10.998Z" }
]
}
]
}
]
}
The second item was mandatory and nobody counted it, so it was set to 0 at submission — location
UNKNOWN, dated at the moment of submission. The full schema is on
Stock count completed — event schema.
The count
| Field | Always present | Meaning |
|---|---|---|
tenantId, businessUnitId | yes | The tenant and the store counted |
stockCountId | yes | The count |
type | yes | FULL, CYCLE or CONTROL — see Types of count. Treat it as an open list. |
submittedAt | yes, may be null | When the count was submitted |
submittedBy | yes, may be null | Opaque identifier of the user who submitted it — not a name |
comment | no | Free text, if the count has one |
numberOfItemsCounted | yes | Items in the whole count with at least one line, including those set to 0 at submission |
numberOfItemsNotCounted | yes | Listed items with no line at all — the optional items nobody counted. Always 0 for a control count. |
messageNumber, lastMessageNumber | yes | Which part of the count this message is — see below |
expectedCostAmount, actualCostAmount | no | The expected and counted value of the whole count, where known |
firstCountedDateTime, lastCountedDateTime | no | The earliest and the latest line in the whole count |
items | yes | The items — at most 1 000 per message |
An item
| Field | Always present | Meaning |
|---|---|---|
itemId, itemIdentifier | yes, one may be null | The item's id and its identifier (a barcode such as a GTIN). Both keys are always present; at least one of the two is a string, and the other may be null. |
itemName | no | The item's name when it was counted |
actualQuantity | yes | 🛑 The counted quantity — the total across all locations. A level, not a change. |
expectedQuantity | no | The quantity Stock Count expected when the item was counted — see below |
unitCostPrice | no | The item's cost per unit used to value the count |
actualCostAmount, expectedCostAmount | no | actualQuantity and expectedQuantity valued at unitCostPrice |
firstCountedDateTime | yes, may be null | 🛑 When the item was counted — its earliest line, at any location. The time to apply the count as of. |
lastCountedDateTime | yes, may be null | Its latest line |
locations | yes | Where it was counted, each with its own actualQuantity, first and last time, and lines |
A line
| Field | Always present | Meaning |
|---|---|---|
lineId | yes | The line |
actualQuantity | yes | The quantity registered on this line |
countedDateTime | yes, may be null | When the line was registered in Stock Count |
batchNumber, serialNumber, businessPartnerId | no | Present when the line was counted per batch or serial number |
itemIdentifier, unitCostPrice | no | Present when known |
Locations are named by whoever counted: the Hii Retail Inventory app numbers them 1, 2, 3;
an integration can use any name. UNKNOWN marks a line Stock Count added itself, for a mandatory item
nobody counted.
🛑 One count can arrive as several messages
A count is published in parts of at most 1 000 items. Each message carries messageNumber (from 1)
and lastMessageNumber, and repeats the count-level fields. An item is only ever in one part, so each
message can be applied on its own. To know you have the whole count, collect every messageNumber from
1 to lastMessageNumber.
The count-level fields — the counters, the cost totals, firstCountedDateTime and lastCountedDateTime
— describe the whole count and are the same in every part. Do not add them up across parts.
🛑 actualQuantity is what was counted — not the change, and not the new stock figure
The event says how many were found, and when. It does not say how the stock changed, because that
depends on what happened after the count: sales that continued while the count was open, deliveries
registered in the afternoon, and so on. Stock Transaction Processing works out the change by applying
the counted quantity as of the item's firstCountedDateTime — see
Counting while the store is open.
expectedQuantity is Stock Count's copy of STP's quantity at the moment the item was counted, kept for
the deviation review and for reporting. It is never negative — negative stock shows as 0 — and it takes
no account of movements between the count and the submission. Do not compute the stock adjustment as
actualQuantity − expectedQuantity; that is a report of the deviation seen while counting, not the
correction to book.
⚠ The cost amounts value the count, not the stock movement. STP values the resulting STOCK_COUNT
transaction with its own costing. A financial consumer that takes both this event's amounts and STP's
processed transactions for the same store books the same loss twice — take one source. See
Stock ownership.
Message attributes
| Attribute | Value |
|---|---|
Tenant-Id | the tenant |
Business-Unit-Id | the store |
Stock-Count-Id | the count — equals stockCountId |
Correlation-Id | ties the message to the request that submitted the count |
Event-Type | always SUBMIT_STOCK_COUNT |
Stock count changed
stc.public.event.stock-count-changed.v1
A snapshot of a count's settings and status, published when the count is created or edited. Each message is complete in itself — never a delta — so a consumer that missed earlier messages can act on any one of them.
{
"tenantId": "acme",
"businessUnitId": "store-42",
"recordId": "0b8e4f6a-2f0c-4a57-9d61-6d1f6f2b7c10",
"parentStockCountId": "0b8e4f6a-2f0c-4a57-9d61-6d1f6f2b7c10",
"type": "CYCLE",
"status": "CREATED",
"startDate": "2026-09-28T00:00:00.000Z",
"dueDate": "2026-10-01T00:00:00.000Z",
"modifiedDatetime": "2026-09-23T09:14:02.076Z"
}
| Field | Always present | Meaning |
|---|---|---|
tenantId, businessUnitId | yes | The tenant and the store |
recordId | yes | The count, as it is known in its store — the same id the completed event calls stockCountId |
parentStockCountId | yes | The parent the count belongs to. Present even when it equals recordId, which it does for every count on this topic today. |
type | yes | FULL, CYCLE or CONTROL |
status | yes | The count's status when the message was published |
startDate | yes | The day the count starts, as midnight UTC |
dueDate | no | The day it is due, as midnight UTC. Absent — not null — when the count has no due date. |
modifiedDatetime | yes | When the count was last written. Use it to discard stale messages — see below. |
Event-Type attribute | Published when |
|---|---|
STOCK_COUNT_CREATED | a count is created with POST /stock-counts — including full counts created in the app — or generated from a recurring definition |
STOCK_COUNT_UPDATED | a count's settings are edited with PUT /stock-counts/{stockCountId} |
The kind of change is an attribute, not a payload field, so a subscription can filter on it without
reading the body: attributes.Event-Type = "STOCK_COUNT_CREATED". Every message also carries
Tenant-Id, Business-Unit-Id, Stock-Count-Id (equal to recordId) and Correlation-Id.
⚠ It is not a status feed. Nothing is published on this topic when counting starts, when a count is submitted, or when it is deleted. To learn that a count was completed, subscribe to stock count completed.
All three timestamps have exactly three fractional digits in UTC (2026-09-23T09:14:02.076Z), so
comparing modifiedDatetime values as strings gives the right order.
Consuming the events
Delivery is at least once. A message can arrive more than once — a redelivery, or a submission that is retried after a failure, publishes again.
| Event | Deduplicate on |
|---|---|
| stock count completed | stockCountId + messageNumber |
| stock count changed | recordId + modifiedDatetime |
Do not rely on arrival order. For the changed event, keep the modifiedDatetime you last applied for
each count and discard any message that is not newer. For the completed event, order does not matter
within a count — the parts are independent. When one item is counted by two different counts, apply
them by the item's firstCountedDateTime, not by arrival.
Use the attributes to filter. Tenant-Id and Business-Unit-Id are on every message of both
events; filter on them rather than parsing the body.
New optional fields and new values do not mean a new version. Adding an optional field is not a
breaking change, so it is added to the existing version, and the published schema gains it before any event
carries it. Ignore fields you do not recognise, and if you validate against the schema, use the current
published copy rather than one you saved. Enumerations such as type and status can gain values, so handle
one you do not recognise rather than rejecting the message. A new version is published only for a breaking
change — a field removed, renamed, retyped or made required.
Levels, not deltas. Every quantity on the completed event is a level — what was found, or what was expected — never a change. The changed event carries no quantities.
Three different times. countedDateTime and firstCountedDateTime say when something was counted;
submittedAt says when the count was closed; neither is when you received the message. If you apply a
count to your own stock, apply it as of firstCountedDateTime per item — see
Stock ownership.
What may be missing. comment, itemName, the cost fields, expectedQuantity and the per-line batch,
serial and business-partner fields can be absent or null; submittedAt, submittedBy and the counted
times are always present but may be null. An item always carries both itemId and itemIdentifier, and
one of them may be null. Only dueDate is omittable on the changed event.
Read next
- Stock count completed — event schema — the full JSON schema
- Counting while the store is open — why the count time decides the result
- Stock ownership — applying a count in an external system