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:
| Concern | Endpoints |
|---|---|
| Onboard a SKU | createsku → uploadmedia / setprimaryimage → addbarcodes → activate via updatesku |
| IMS bulk sync | bulkupsert keyed by natural key, getdeletedskus/{ticks} for deletion polling |
| Lifecycle | LifecycleState on updatesku; admin relaunch/{id} |
| Price audit | GetPriceHistory/{id} |
| Barcodes | addbarcodes / updatebarcodes / deletebarcodes / getskuwithbarcode |
| SKU groups | createskugroupname → createskugroup → getskugroupforplace |
SKU endpoints are gated on the Sku module permission; SKU-group endpoints
on SkuGroup; relaunch on Administration Update.
Prerequisites
- A token with
Skucreate/update permission — see Getting started. - An Item-type template id (
ItemTemplateIdis required on every SKU) — find one withGET api/templates/getfilteredtemplatesfiltered toTemplateType eq 'Item', or create one per Templates & extended properties. - Your reference-data dims created (or
Organisation.AutoCreateReferenceDataenabled) — see Reference data setup and Identifiers & natural keys.
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.
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 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
Failedwith"Batch rolled back: …". - There is no
ErrorCodefield — assert onStatus+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 conventions —
400 with a JSON array of { ErrorCode, Message, Field } — except where
noted:
| Situation | Response |
|---|---|
Missing Name / SkuNumber / SkuType / ItemTemplateId on create | 400 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 off | 400 on createsku/updatesku; per-row Failed on bulkupsert |
Disallowed lifecycle transition via updatesku | 200 — state unchanged, other fields commit; compare returned LifecycleState |
Disallowed transition / price edit on Discontinued, in bulkupsert | 200 with that row Status: Failed |
bulkupsert row missing the UpsertKey property | 200, all rows Failed, nothing written |
relaunch on a non-Discontinued SKU | 400 InvalidOperation |
GetPriceHistory with an unknown SKU id | 200 with an empty page (not 400) |
deletebarcodes matching nothing | 304 |
| Delete a SKU still referenced by items (non-cascade) | 400 |
SKU group with an unknown SkuGroupNameId or SkuIds entry | 400 |
| Update/delete with an unknown id | 400 NotFound (SKU-group endpoints often return an empty array instead) |
No token / wrong scheme (Bearer) | 401 |
| Token lacks the module permission | 403 |
Related
- SKU lifecycle — the state machine and operational lock behind steps 4 and 8.
- Reference data setup — previous in the setup path; the dims SKUs reference by natural key.
- Templates & extended properties — the Item template every SKU is created against.
- Identifiers & natural keys —
ExternalRef/Code/Nameresolution rules. - Reference pages: Create SKU, Update SKU, Get SKU, List SKUs, SKU lookup list, Bulk upsert, Deleted SKUs, Price history, Relaunch, Barcode lookup, SKU groups (filtered).