Skip to main content

SKU management

A SKU is Loca.fi's product/item-type definition — the shared name, encoding, classification, and pricing that every tracked item of that type inherits. This guide is the third stop on the setup path: create your reference data (brands, categories, seasons, …) and your Item templates first, because a SKU references both at create time. Once a SKU is Active, items, tags, and order lines can be created against it — the SKU lifecycle concepts page explains the state machine this guide drives.

Overview

Everything lives on two controllers:

ConcernEndpoints
Onboard a SKUcreateskuuploadmedia / setprimaryimageaddbarcodes → activate via updatesku
IMS bulk syncbulkupsert keyed by natural key, getdeletedskus/{ticks} for deletion polling
LifecycleLifecycleState on updatesku; admin relaunch/{id}
Price auditGetPriceHistory/{id}
Barcodesaddbarcodes / updatebarcodes / deletebarcodes / getskuwithbarcode
SKU groupscreateskugroupnamecreateskugroupgetskugroupforplace

SKU endpoints are gated on the Sku module permission; SKU-group endpoints on SkuGroup; relaunch on Administration Update.

Prerequisites

Step-by-step

1. Create the SKU

POST api/skus/createsku takes the full definition in one call. Dim references accept natural keys — for each dim, supply one of <Dim>Id, <Dim>ExternalRef, or <Dim>Name (precedence Id → ExternalRef → Name; for MerchandiseDepartment and Season, ExternalRef matches the dim's Code):

curl -sk -X POST "http://<host>/api/skus/createsku" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Aero Tee Blue M","SkuNumber":"AT-0413-BL-M",
"ExternalRef":"ims_84213","ItemTemplateId":"<itemTemplateId>",
"SkuType":"Standard","SkuEncodingType":"Sgtin",
"CompanyPrefix":"931234","ItemReference":"567890",
"BarcodeNumber":"9312345678907",
"BrandName":"Aero","CategoryExternalRef":"cat_tees",
"SeasonExternalRef":"SS26","ColourName":"Blue","SizeName":"M",
"StyleExternalRef":"style_aero_tee",
"MerchandiseDepartmentExternalRef":"MD-APP",
"PrimarySupplierExternalRef":"sup_acme","Gender":"Mens",
"RetailPrice":59.95,"SalePrice":49.95,"MemberPrice":44.95,
"LifecycleState":"Draft",
"SkuExtendedPropertyList":[
{"ExtendedPropertyId":"<fabricEpId>","Value":"Cotton"}]}'

Returns 200 with the SkuDetailDto — capture Id. Key validation rules: Name, SkuNumber, SkuType, and ItemTemplateId are required; Sgtin encoding needs a numeric 6–12-digit CompanyPrefix whose combined length with ItemReference is 7, 11, or 12 (GTIN-8/12/13); barcode numbers and GS1 prefixes are unique per organisation (scoped to non-deleted SKUs); extended properties must belong to the SKU's Item template, and required ones need values; SalePrice/MemberPrice must each be ≤ RetailPrice.

Omitting LifecycleState creates the SKU Active (legacy default) — pass "Draft" explicitly for staged setup so nobody can create items or order lines against a half-configured product. Draft SKUs accept price edits, media, and barcodes; they just aren't operational yet.

Retail vs Asset

The three prices (RetailPrice = RRP, SalePrice = promotional, MemberPrice = loyalty) plus SKU-level UnitCost (supplier cost) are the retail pricing model. Asset-management tenants instead track cost per individual item (ItemMaster.UnitCost) and leave these SKU prices unset. Set IsSkuLevelCost: true (which makes UnitCost required) when cost belongs to the product, not the individual asset.

2. Attach media

POST api/skus/uploadmedia is multipart form data: for each file, one form field whose value is a JSON metadata object (OwnerId = the SKU id, plus IsPrimaryImage, MediaType"Image", "Document", "Hyperlink", "Archive", "Other" — and UserDescription) and one file part under the same field name:

curl -sk -X POST "http://<host>/api/skus/uploadmedia" \
-H "Authorization: Token $TOKEN" \
-F 'img1={"OwnerId":"<skuId>","IsPrimaryImage":true,"MediaType":"Image","UserDescription":"Front view"}' \
-F 'img1=@front.jpg'

Returns 200 with true. The primary image drives the Thumbnail signed URL on SKU list rows (valid for one hour). Change it later with setprimaryimage/{id}/{mediaId}; remove media with deletemedia/{id}/{mediaId}.

3. Add additional barcodes

A SKU has one primary BarcodeNumber plus any number of additional barcodes — old GTINs, supplier codes, pack-level barcodes:

curl -sk -X POST "http://<host>/api/skus/addbarcodes" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"SkuId":"<skuId>","BarcodeNumbers":["19312345678904","5012345678900"]}'

Returns the updated SkuDetailDto (barcodes appear in AdditionalBarcodes with their record ids). Values are trimmed and checked for duplicates against the primary and existing additional barcodes. Scanning clients resolve any of them with GET api/skus/getskuwithbarcode/{barcodeNumber}. Retire one with DELETE api/skus/deletebarcodes — note it is a DELETE with a JSON body ({ SkuId, SkuBarcodeIds }), which some HTTP clients need a workaround for; nothing-matched returns 304.

4. Activate

Lifecycle transitions ride on POST api/skus/updatesku. The update is full-replace: fetch the current state with getsku/{skuId}, change LifecycleState to "Active", and send the whole object back — a null price or dim field clears the stored value rather than leaving it unchanged.

A rejected transition still returns 200

A LifecycleState the state machine disallows is silently ignored: the response is 200, every other field change commits, and the stored state is unchanged. Compare the returned LifecycleState against what you submitted — see SKU lifecycle and the 200-despite-failure family.

Once Active, the SKU is operational — items, tag generation, auto-create on scan, and new order lines (e.g. item-create-item) all unblock.

5. Change prices — and read the audit trail

Price changes are ordinary updatesku calls. Whenever RetailPrice, SalePrice, or MemberPrice actually changes, the server stamps PriceEffectiveFrom = now and appends an immutable SkuPriceHistory row — there is no separate price-write endpoint, and no way to write history directly. Read the trail (OData-filterable, newest first):

curl -sk "http://<host>/api/skus/GetPriceHistory/<skuId>?%24filter=ChangedAt%20gt%202026-01-01T00:00:00Z" \
-H "Authorization: Token $TOKEN"

Each SkuPriceHistoryDto row carries old + new values of all three prices, ChangedAt (UTC), and ChangedByUserId/ChangedByUserFullName (null for system-driven changes). An unknown SKU id returns 200 with an empty page — not this API's usual not-found 400. Price edits on a Discontinued SKU are rejected and reverted.

6. Bulk product sync from an IMS

POST api/skus/bulkupsert merges a product feed in one set-based call. Pick the natural key your IMS keys products by — UpsertKey is Id, ExternalRef, SkuNumber, or BarcodeNumber, and every row in the batch is matched by that one property:

curl -sk -X POST "http://<host>/api/skus/bulkupsert" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"UpsertKey":"ExternalRef","Skus":[
{"ExternalRef":"ims_84213","Name":"Aero Tee Blue M",
"SkuNumber":"AT-0413-BL-M","ItemTemplateId":"<itemTemplateId>",
"SkuType":"Standard","SkuEncodingType":"None",
"BrandExternalRef":"br_aero","SeasonExternalRef":"SS26",
"RetailPrice":59.95,"SalePrice":49.95}]}'

The response is always 200 with one BulkUpsertResultDto per input row, in order: { ExternalRef, Id, Status, ErrorMessage } where Status is Created, Updated, Unchanged, or Failed. Handle it per row:

  • A row missing the selected key fails the whole batch (every row comes back Failed, nothing is written).
  • A per-row problem — unresolvable dim ref, unknown ItemTemplateId, invalid extended-property value, intra-batch duplicate key, rejected lifecycle transition — fails only that row; the rest of the batch commits.
  • An unexpected server error mid-batch marks every remaining row Failed with "Batch rolled back: …".
  • There is no ErrorCode field — assert on Status + ErrorMessage.

Template changes, SkuType changes, and InnerSku are not supported in bulk — those rows fail; use updatesku instead. Dim resolution and auto-create behave exactly as in reference-data setup. For deletion sync, poll GET api/skus/getdeletedskus/{ticks} with a .NET-ticks watermark (any authenticated caller) to tombstone SKUs deleted since your last sync.

7. Group SKUs for counting

SKU groups scope cycle counts and stocktakes to a slice of the catalogue. Two steps — a reusable name, then the group binding it to SKUs and places:

curl -sk -X POST "http://<host>/api/skugroups/createskugroupname" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"SS26 Apparel"}'

curl -sk -X POST "http://<host>/api/skugroups/createskugroup" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"SkuGroupNameId":"<nameId>","SkuIds":["<skuId1>","<skuId2>"],
"PlaceIds":["<storePlaceId>"]}'

An empty PlaceIds list makes the group global. Unknown SkuIds fail validation; unknown PlaceIds are silently dropped. Handhelds discover the groups relevant to where they are with GET api/skugroups/getskugroupforplace/{placeId}?includeGlobalGroups=true. Membership updates via updateskugroup are full-replace — a null or omitted list empties the group's SKUs or places. Browse names with getfilteredskugroupnames.

8. Wind down — clearance, EOL, discontinue, relaunch

End-of-life is the same updatesku mechanism walking the state machine: Active → Clearance (still fully operational, and the only state with a rollback to Active), then EOL, then Discontinued (items/tags/orders and price edits all blocked; existing order lines keep working). Deleting is separate from lifecycle: DELETE api/skus/deletesku/{id} is rejected while items reference the SKU, and deletesku/{id}/{cascade} with cascade=true removes the items first — the cascade is not transactional.

To bring a discontinued product back, an administrator calls POST api/skus/relaunch/{id} — the only path for Discontinued → Draft. It requires Administration Update permission (not Sku), returns the detail DTO now in Draft, and a SKU in any other state gets 400 InvalidOperation.

Error handling

All failures follow the platform-wide error and response conventions400 with a JSON array of { ErrorCode, Message, Field } — except where noted:

SituationResponse
Missing Name / SkuNumber / SkuType / ItemTemplateId on create400 naming the field
GS1 encoding rules violated (prefix lengths, missing CustomPrefix, …)400
Duplicate barcode number, GS1 prefix pair, or CustomPrefix (per org, non-deleted)400 duplicate resource
Unresolvable dim natural key with auto-create off400 on createsku/updatesku; per-row Failed on bulkupsert
Disallowed lifecycle transition via updatesku200 — state unchanged, other fields commit; compare returned LifecycleState
Disallowed transition / price edit on Discontinued, in bulkupsert200 with that row Status: Failed
bulkupsert row missing the UpsertKey property200, all rows Failed, nothing written
relaunch on a non-Discontinued SKU400 InvalidOperation
GetPriceHistory with an unknown SKU id200 with an empty page (not 400)
deletebarcodes matching nothing304
Delete a SKU still referenced by items (non-cascade)400
SKU group with an unknown SkuGroupNameId or SkuIds entry400
Update/delete with an unknown id400 NotFound (SKU-group endpoints often return an empty array instead)
No token / wrong scheme (Bearer)401
Token lacks the module permission403