Events
Store Transfer publishes two events. Most integrations only ever hear about the second one and then wonder why they find out about transfers so late — the first is the one that tells you goods are coming.
| Event | Published when | Tells you |
|---|---|---|
str.public.event.store-transfer-requested.v1 | the request is sent to the other store | a transfer has been raised, what was asked for, and from where |
str.public.event.store-transfer-completed.v1 | the transfer is completed | what actually shipped, with quantities and values |
Subscribe by the topic name. If you route on the Event-Type attribute instead, the values are
STORE_TRANSFER_REQUESTED and STORE_TRANSFER_COMPLETED.
Message attributes
Both events carry the same attributes, usable as subscription filters:
| Attribute | Value |
|---|---|
Tenant-Id | the tenant |
Business-Unit-Id | the business unit that triggered this step |
Correlation-Id | traces back to the API call that caused it |
Event-Type | STORE_TRANSFER_REQUESTED or STORE_TRANSFER_COMPLETED |
⚠ Business-Unit-Id is the acting store, not the transfer's direction. Do not use it to decide
whether you are the sender or the receiver — read fromBusinessUnitId and toBusinessUnitId from the
payload instead.
Store transfer requested
Sent when a store sends its request to the other store. The goods have not moved; they are reserved.
{
"tenantId": "tenant-1",
"fromBusinessUnitId": "store-42",
"toBusinessUnitId": "store-17",
"storeTransferId": "0f8b7d3c-6f1a-4a2e-9a1c-2b7d0e5f4a3b",
"requestedDateTime": "2026-09-01T08:14:22.145Z",
"orderCode": "S4K9M2X",
"comment": "weekend top-up",
"createdBy": "user-abc",
"requestedBy": "user-abc",
"items": [
{ "lineId": "line-1", "itemId": "7350053850019", "quantity": 6, "comment": null }
]
}
| Field | Notes |
|---|---|
tenantId, fromBusinessUnitId, toBusinessUnitId, storeTransferId, requestedDateTime, items | always present |
orderCode | the short human-readable code, e.g. S4K9M2X. Always begins S for a store transfer. May be null on transfers raised before codes existed |
comment, createdBy, requestedBy | may be null |
items[].quantity | the requested quantity — what was asked for, not what will arrive |
Store transfer completed
Sent when the transfer is completed. The goods have left the sending store.
{
"tenantId": "tenant-1",
"fromBusinessUnitId": "store-42",
"toBusinessUnitId": "store-17",
"storeTransferId": "0f8b7d3c-6f1a-4a2e-9a1c-2b7d0e5f4a3b",
"transferDateTime": "2026-09-01T11:02:57.881Z",
"comment": "weekend top-up",
"createdBy": "user-abc",
"requestedBy": "user-abc",
"completedBy": "user-def",
"items": [
{
"lineId": "line-1",
"itemId": "7350053850019",
"quantityRequested": 6,
"quantityApproved": 4,
"unitPrice": 12.5,
"currencyId": "SEK",
"comment": null,
"lineInfos": [
{ "lineInfoId": "li-1", "quantity": 4, "batchNumber": "B-2291", "bestBeforeDate": "2026-12-01" }
]
}
]
}
| Field | Notes |
|---|---|
tenantId, fromBusinessUnitId, toBusinessUnitId, storeTransferId, transferDateTime, items | always present |
items[].quantityApproved | the quantity that actually moved. This is the number to act on |
items[].quantityRequested | what was originally asked for. Kept so the shortfall is visible |
items[].unitPrice, items[].currencyId | valuation at completion time. Both may be null — see below |
items[].lineInfos | batch, serial and expiry detail where the goods are tracked at that level |
items[].substituteForLineId | set when this line is a substitute for another — see below. Null on ordinary lines |
completedBy | who completed it; may be null |
⚠ bestBeforeDate and expirationDate are plain dates — 2026-12-01, not a timestamp. Only
transferDateTime and requestedDateTime carry a time.
🛑 A null unitPrice means "not known", not "free". The valuation lookup can fail or find no
price, and the line is still published rather than being held back. Summing a null as zero
understates the value of the movement.
⚠ store-transfer-completed does not carry orderCode. If you need the code, take it from the
requested event and key on storeTransferId, or read it from the transfer through the API.
Substitutions add lines that were never requested
A picker who cannot find the requested item may pick something else instead. When that happens the completed event carries more lines than the request did, and the original line is still there:
| Line | itemId | quantityRequested | quantityApproved | substituteForLineId |
|---|---|---|---|---|
line-1 | the requested item | 6 | 0 | null |
sub-9f2c… | the item actually picked | 0 | 4 | line-1 |
🛑 The original line is never removed. It stays on the event with its quantityApproved reduced
to whatever was picked of the original item — 0 when the substitute replaced it entirely, or a
smaller number when the picker found some of both. A consumer that reports "line-1 was short" without
looking for a substitute understates what actually arrived.
The rules:
- Sum
quantityApprovedacross all lines to get what shipped. Original and substitute lines never double-count the same units. - Match lines on
lineId, not on position or order. A substitute'slineIdis derived from its parent and the item, in the formsub-<hash>, and is stable — a redelivery of the same event updates the same line rather than adding another. substituteForLineIdnames the line it stands in for, and that line is always on the same event.- One line can have several substitutes. Two half-measures of different items against one request is an ordinary outcome.
quantityRequestedon a substitute line is always 0. Nobody asked for it; it was offered.
Substitution is a Click and Collect picking feature, so it only appears on transfers picked through
the app. A transfer completed through :submit carries whatever lines were written to it.
Consuming these events
Delivery is at-least-once — expect duplicates. Deduplicate on storeTransferId together with the
event type: a transfer produces exactly one requested and one completed event, so the pair
(storeTransferId, Event-Type) is a safe key. Redelivery of a completed event is not a second
transfer.
Do not rely on arrival order. Order by the event's own timestamp — requestedDateTime on the
requested event, transferDateTime on the completed one. Do not order by the time you received the
message, and do not assume the requested event arrives before the completed one.
Order by event time, not processing time. The timestamps above are when the business step happened. A redelivered or replayed message keeps its original timestamp, which is what makes it usable for ordering.
Handle unrecognised values. Treat enumerations as open — new values can be added without a new event version, so a value you do not recognise should be passed through or ignored, never treated as an error.
New optional fields arrive on the existing version. Adding an optional field is not a breaking change, so it is added to the current version rather than published as a new one, and the published schema gains the field 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. A new version is published only for a breaking change — a field removed, renamed, retyped or made required.
Both stores see both events. The events are not addressed to one party. If you are integrating on
behalf of one store, filter on fromBusinessUnitId / toBusinessUnitId yourself.
Pairing the two events
A transfer is only fully described by both events together:
- the requested event tells you a transfer exists and what was asked for
- the completed event tells you what actually shipped
They share storeTransferId, which is the key to join them. Expect a gap between them — minutes if
someone picks straight away, longer if the transfer waits.
⚠ Not every requested transfer completes. A transfer that is never picked simply stays requested, and no further event is published. If you are tracking transfers in flight, age them out yourself rather than waiting indefinitely for a completed event that may never come.
Subscribing from outside Hii Retail
External systems do not read these topics directly — they receive them through Hii Retail's External Events, which is the supported route for anything outside the platform. Both events are published there with their schemas.