BOM Guide — Headers, Lines & Inheritance
How to read and write Bills of Materials (BOMs) through the Retraced public API, and — more importantly — how BOM inheritance and copy-on-write work. If you only read one section, read The Mental Model. It explains the one behavior that surprises every integrator: modifying an inherited BOM changes its IDs.
Setup
BASE_URL="https://publicapi.retraced.com/api/v2"
API_KEY="your-api-key" # provided by Retraced; wrap in single quotes if it contains $
STYLE_ID="style-123" # the product you are working with
Requests are authenticated with the API key header:
curl "$BASE_URL/bom-headers?styleId=$STYLE_ID" \
-H "companyapikey: $API_KEY"
All responses use the standard envelope:
{
"metadata": { "success": true },
"data": { }
}
The Mental Model
Data model
A BOM is not stored per product. It is stored once and linked to products:
BOM Header (name, description) ── the BOM itself, stored once
│
├── BOM Links (styleId, isOwner, isMainBom, version, effective dates)
│ ── one link per product that "has" this BOM
│
└── BOM Lines (component styleId, quantity, weight, placement, ...)
── the components inside the BOM
Products form a 3-level hierarchy — Style → Variant → SKU — and all three levels are addressed by the same kind of ID (the styleId parameter accepts a style, variant, or SKU ID).
Inheritance = shared links
When you create a BOM on a style, the platform automatically links that same header to every variant and SKU underneath it:
Style "Summer T-Shirt" ──link (isOwner: true)──┐
├─ Variant "Red" ──link (isOwner: false)─┼──► BOM Header "T-Shirt Assembly"
│ └─ SKU "Red / M" ──link (isOwner: false)─┤
└─ Variant "Blue" ──link (isOwner: false)─┘
- The product that created the BOM owns it (
link.isOwner: true). - Children inherit it (
link.isOwner: false) — they see the exact same header and lines. There is no copy; it is literally the same record. - Because it's the same record, edits made by the owner are instantly visible to every inheriting child.
- Inheritance only flows downward, so a top-level style always owns its BOMs; only variants and SKUs can inherit.
Note: Child links are created asynchronously. After creating a BOM on a style, the variant/SKU links appear within a few seconds — don't expect them synchronously in the create response.
Copy-on-write: what happens when a child edits an inherited BOM
A child cannot edit a BOM it doesn't own — that would silently change its siblings and parent. Instead, the platform performs copy-on-write:
- The child's link to the shared header is deleted.
- The header is duplicated — new header ID, name suffixed with
" (Copy)"(unless your request supplied a new name), all lines copied with new line IDs. - The child gets a new link to the copy with
isOwner: trueandversion: 1. - The child is now permanently detached: future changes to the parent's BOM no longer reach it. Its own children are not moved over — copy-on-write never creates child links, so a forked variant's SKUs keep their links to the original shared header.
Before After variant "Red" edits a line
Style ──(owner)───► Header A Style ──(owner)───► Header A
Variant ──(inherit)─► Header A Variant ──(owner)───► Header A (Copy) ← new IDs
SKU ──(inherit)─► Header A SKU ──(inherit)─► Header A ← stays on original
⚠️ The single most important consequence: after copy-on-write, the BOM header ID and every BOM line ID are different. Never cache BOM IDs across writes — always take the IDs from the response of your last write.
⚠️ Copy fidelity caveat: lines copied during copy-on-write currently do not preserve the component reference (
styleId) or theisMainComponentflag — only the line you explicitly created/edited in the same request carries the values you sent. After a fork, re-fetch the copied BOM's lines and re-apply component references where needed.
What triggers copy-on-write — and what doesn't
Operation via a product with an inherited link (isOwner: false) |
Copy-on-write? |
|---|---|
PUT /bom-headers/:id changing name / description |
Yes — child gets its own copy |
PUT /bom-headers/:id changing only isMainBom |
No — isMainBom lives on the link, which is already per-product (see Main BOM & Versioning) |
POST /bom-lines (add a line) |
Yes — BOM is duplicated first, the new line goes into the copy |
PUT /bom-lines/:id (edit a line) |
Yes — BOM is duplicated, the edit lands on the copied line |
PUT /bom-lines/:id with no actual changes |
No — short-circuits and returns existing data |
POST /bom-lines/delete (remove lines) |
Yes — BOM is duplicated without the deleted lines |
POST /bom-headers/delete |
No — the product detaches from the BOM (its link and its descendants' links are removed; the shared header is untouched) |
Operations through the owner (isOwner: true) always modify the shared record directly — every inheriting child sees the change immediately.
Endpoint Reference
| Endpoint | Purpose |
|---|---|
GET /bom-headers?styleId=... |
List BOMs linked to a product (any level) |
GET /bom-headers/:id |
Get one BOM header (resolves via its owner link only) |
POST /bom-headers |
Create a BOM on a product |
PUT /bom-headers/:id |
Rename / set main BOM (subject to copy-on-write) |
POST /bom-headers/delete |
Delete BOMs / detach inherited BOMs |
GET /bom-lines?bomHeaderId=... |
List the lines (components) of a BOM |
POST /bom-lines |
Add a component to a BOM |
PUT /bom-lines/:id |
Edit a component (subject to copy-on-write) |
POST /bom-lines/delete |
Remove components (subject to copy-on-write) |
Placements (the position a component occupies, e.g. "Front placket") are managed via GET /bom-placements and POST /bom-placements — see the API Reference. Use GET /bom-placements to find the placementId values used on BOM lines.
Reading BOM Data
Step 1 — list a product's BOM headers
styleId is required and can be a style, variant, or SKU ID.
curl "$BASE_URL/bom-headers?styleId=$STYLE_ID" \
-H "companyapikey: $API_KEY"
Response (200):
{
"metadata": {
"success": true,
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
},
"data": [
{
"id": "bomhdr_01J9XYZ...",
"name": "T-Shirt Assembly",
"description": null,
"forCompanyId": "company-123",
"version": 1,
"effectiveStartDate": "2026-06-01T08:00:00.000Z",
"effectiveEndDate": null,
"isArchived": false,
"link": {
"styleId": "style-123",
"isMainBom": true,
"isOwner": true
},
"createdAt": "2026-06-01T08:00:00.000Z",
"updatedAt": "2026-06-01T08:00:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123"
}
]
}
Read link.isOwner to know whether this product owns the BOM or inherits it — that tells you whether your next write will hit the shared record or trigger copy-on-write. version, effectiveStartDate/effectiveEndDate, and isMainBom are also per-link values (see Main BOM & Versioning).
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
styleId |
string | Yes | — | Style / variant / SKU ID |
page |
number | No | 1 | Page number |
limit |
number | No | 20 | Max 100 |
sort |
string | No | createdAt |
createdAt, updatedAt, name, version, effectiveStartDate, effectiveEndDate |
order |
string | No | desc |
asc, desc |
ids |
string | No | — | Comma-separated header IDs |
name |
string | No | — | Substring match, case-insensitive |
isArchived |
boolean | No | — | See boolean gotcha below |
isMainBom |
boolean | No | — | See boolean gotcha below |
version |
number | No | — | Filter by link version |
Boolean filters accept
trueorfalse. PassisMainBom=false/isArchived=falseto filter for thefalsevalue, or omit the parameter to skip the filter. Any other value (e.g.1,yes) is rejected with a400.
Step 2 — list the BOM's lines
BOM_HEADER_ID="bomhdr_01J9XYZ..."
curl "$BASE_URL/bom-lines?bomHeaderId=$BOM_HEADER_ID&include=entity,placement" \
-H "companyapikey: $API_KEY"
Response (200):
{
"metadata": {
"success": true,
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
},
"data": [
{
"id": "bom_line_01J9ABC...",
"bomHeaderId": "bomhdr_01J9XYZ...",
"placementId": "plc_01J9...",
"styleId": "style-button-456",
"quantity": 4,
"quantityUnit": "PIECES",
"weight": 2.5,
"weightUnit": "GRAM",
"countryOfOrigin": "PT",
"isMainComponent": false,
"createdAt": "2026-06-01T08:05:00.000Z",
"updatedAt": "2026-06-01T08:05:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123",
"entity": {
"id": "style-button-456",
"forCompanyId": "company-123",
"name": "4-Hole Button 12mm",
"codes": [{ "codeId": "ERP_ID", "codeValue": "BTN-12-4H" }],
"suppliers": ["supplier-9"],
"mainSupplier": "supplier-9"
},
"placement": {
"id": "plc_01J9...",
"value": "Front placket",
"forCompanyId": "company-123"
}
}
]
}
⚠️
styleIdmeans the component here, not the product. On a BOM line,styleIdis the ID of the component product (the button, the fabric panel) that sits inside the BOM. Consequently thestyleIdquery filter onGET /bom-linesfilters by component, answering "which BOMs use this component?" — not "what are this product's lines?". To get a product's lines, go through its headers:GET /bom-headers?styleId=...→GET /bom-lines?bomHeaderId=....
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
bomHeaderId |
string | No | — | Lines of a specific BOM (the usual entry point) |
styleId |
string | No | — | Filter by component product ID |
placementId |
string | No | — | Filter by placement |
countryOfOrigin |
string | No | — | Substring match, case-insensitive |
isMainComponent |
boolean | No | — | Same true-only gotcha as above |
include |
string | No | — | Comma-separated: entity (component details + codes), placement |
page |
number | No | 1 | Page number |
limit |
number | No | 20 | Max 100 |
sort |
string | No | createdAt |
createdAt, updatedAt, quantity, weight |
order |
string | No | desc |
asc, desc |
Get a single header
GET /bom-headers/:id returns the same header shape as the list, but resolves only through the owner's link. If the header doesn't exist — or exists but is only inherited by your products — it responds 404 bom_header_not_found. When in doubt, use the list endpoint with styleId, which returns both owned and inherited BOMs.
curl "$BASE_URL/bom-headers/$BOM_HEADER_ID" \
-H "companyapikey: $API_KEY"
Creating a BOM
Create the header
Attach it to the level where it's shared. Attaching to a style propagates it to all variants and SKUs; attaching to a variant propagates to that variant's SKUs only; attaching to a SKU doesn't propagate anywhere.
curl -X POST "$BASE_URL/bom-headers" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "T-Shirt Assembly",
"description": "Main production BOM",
"link": {
"styleId": "style-123",
"isMainBom": true
}
}'
Response (201) — note this is a flat shape, without the link object the list endpoint returns:
{
"metadata": { "success": true },
"data": {
"id": "bomhdr_01J9XYZ...",
"name": "T-Shirt Assembly",
"description": "Main production BOM",
"forCompanyId": "company-123",
"isMainBom": true,
"version": 1,
"effectiveStartDate": "2026-06-01T08:00:00.000Z",
"effectiveEndDate": null,
"isArchived": false,
"createdAt": "2026-06-01T08:00:00.000Z",
"updatedAt": "2026-06-01T08:00:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123"
}
}
The creating product owns the new BOM; to see link.isOwner (and version/effective dates), use GET /bom-headers?styleId=.... Child links (isOwner: false) are created asynchronously in the background.
BOM_HEADER_ID="bomhdr_01J9XYZ..." # from the response
Add lines
Each line adds one component (which must itself be a product in your catalog, with at least one product code — e.g. an ERP ID):
curl -X POST "$BASE_URL/bom-lines" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bomHeaderId": "'$BOM_HEADER_ID'",
"entity": { "styleId": "style-123" },
"component": { "styleId": "style-button-456" },
"placementId": "plc_01J9...",
"quantity": 4,
"quantityUnit": "PIECES",
"weight": 2.5,
"weightUnit": "GRAM",
"countryOfOrigin": "PT",
"isMainComponent": false
}'
Two distinct product references — don't mix them up:
| Field | Meaning |
|---|---|
entity.styleId |
The product whose BOM you are editing (must be linked to bomHeaderId). Determines ownership → copy-on-write. |
component.styleId |
The product being added as a component. Must exist and have at least one code (400 component_codes_not_found otherwise). |
quantity/weight are optional non-negative numbers; quantityUnit/weightUnit must be platform unit enum values (PIECES, METRE, SQUARE_METRE, GRAM, KILOGRAM, … — see the OpenAPI reference at https://publicapi.retraced.com/api/docs for the full lists).
Response (201): the created line. If entity.styleId only inherited the BOM, copy-on-write fired and the response's bomHeaderId is a new header — see The Mental Model.
What a BOM line records — and what it doesn't
- Component type vs placement are different axes. A BOM line points at a component product via
component.styleId; what kind of component it is (button, zipper, main fabric, …) comes from that product'sstyleTypeId— resolve it withGET /styles/{component.styleId}. Placement is where the component sits on the garment: setplacementIdon the line and look the value up viaGET /bom-placements. One says what, the other says where — a "Front placket" placement can hold a button, a zipper, or a label. - One quantity and one weight per line. Each line has a single
quantity+quantityUnitand a singleweight+weightUnit. To capture two different measurements of the same component — e.g. a fabric's width and its yarn count — add two BOM lines for that component, one per measurement. - Recycled/synthetic content is not a BOM-line field. There is no
isRecycledflag on a BOM line. Whether a material is recycled or synthetic lives on the raw material and is read through material composition (GET /material-lines?...&include=rawMaterials→rawMaterial.isRecycled/rawMaterial.isSynthetic), independently of the BOM. See the Materials Guide.
Modifying a BOM
The same request behaves differently depending on the isOwner flag of the link between the entity you pass and the header — check it first via GET /bom-headers?styleId=....
Update the header
curl -X PUT "$BASE_URL/bom-headers/$BOM_HEADER_ID" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"name": "T-Shirt Assembly v2",
"description": "Updated",
"isMainBom": true
}'
All body fields except description are required. styleId identifies through which product you are editing:
- Owner: header updated in place — all inheriting children see the new name/description.
- Inherited + name/description changed: copy-on-write. The response
data.idis the new header ID for this product (named with your suppliedname, not"(Copy)"). - Inherited + only
isMainBomchanged: no copy —isMainBomis a per-link property, so the change applies to this product's link only.
Edit a line
BOM_LINE_ID="bom_line_01J9ABC..." # from the list or create response
curl -X PUT "$BASE_URL/bom-lines/$BOM_LINE_ID" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity": { "styleId": "variant-red-789" },
"component": { "styleId": "style-button-456" },
"quantity": 6,
"quantityUnit": "PIECES"
}'
entity and component are required; component.styleId is the line's component (send the current one back unless you're swapping the component). Here entity.styleId is the variant "Red" from the inheritance diagram — an inherited link, so copy-on-write applies:
- Owner: line updated in place.
- Inherited: the entire BOM is duplicated for
entity.styleId, and your edit lands on the copied line. The response contains the new line ID and newbomHeaderId— discard the old ones. - If nothing actually changed, the call short-circuits and returns the existing line (no duplication).
⚠️ Omitted optional fields are cleared, not kept. The update sets every optional field (
placementId,quantity,weight,countryOfOrigin, units,isMainComponent) to the value you send, defaulting tonull/falsewhen omitted. Always send the full desired state of the line.
Delete lines
curl -X POST "$BASE_URL/bom-lines/delete" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "variant-red-789",
"bomLineIds": ["bom_line_01J9ABC..."]
}'
Here styleId is the product whose BOM you are editing (the entity) — unlike the GET /bom-lines filter, where it means the component. Up to 100 IDs per call.
- Owner: lines are hard-deleted from the shared BOM.
- Inherited: copy-on-write — the BOM is duplicated without the deleted lines, and the product is detached from the shared BOM.
Response (200): { "deletedLines": [...], "duplicatedHeaders": [], "orphanedHeaders": [], "errors": [] }
Two things to know about this endpoint:
- It is all-or-nothing: if any requested ID doesn't exist (or isn't linked to
styleId), the whole request fails with404 bom_lines_not_found— nothing is deleted and theerrorsarray is never used. - After a copy-on-write delete, the response does not contain the new header/line IDs. Re-fetch via
GET /bom-headers?styleId=...→GET /bom-lines?bomHeaderId=....
Delete / detach a BOM header
curl -X POST "$BASE_URL/bom-headers/delete" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"bomHeaderIds": ["bomhdr_01J9XYZ..."]
}'
- Through the owner: the header and all its lines are deleted, and every inherited child link is removed in the same operation (synchronously). The BOM is gone for the whole product family.
- Through an inheriting child: the child detaches from the BOM — its link and its own descendants' links are removed (detaching a variant also detaches that variant's SKUs). The parent and siblings keep the BOM.
Response (200): { "deletedHeaders": [...], "deletedLinks": [...], "errors": [...] } — unlike line deletion, per-ID failures are reported in errors without failing the whole request.
Main BOM & Versioning
isMainBom, version, and effectiveStartDate/effectiveEndDate live on the link, so each product in the hierarchy designates its own main BOM independently — without copy-on-write.
- One main BOM per product. Setting
isMainBom: trueon one link automatically demotes the product's previous main BOM (the demoted link getseffectiveEndDate = nowand a version bump). effectiveStartDateis set when a BOM becomes main;effectiveEndDateis stamped when it is automatically demoted. Together they record when this BOM was the active production spec for that product.versionincrements on eachisMainBomtransition for that link, and resets to1for a header created by copy-on-write.- Changing
isMainBomon a style/variant cascades the change to its children's links asynchronously (children that explicitly chose their own main BOM keep their choice).
Error Reference
| Status / code | Where | Meaning |
|---|---|---|
401 |
All endpoints | Missing or invalid companyapikey |
404 entity_not_found |
Header/line create & update | A referenced product ID (link.styleId, entity.styleId, component.styleId) doesn't exist in your catalog |
404 bom_header_not_found |
GET/PUT /bom-headers/:id |
Header doesn't exist, isn't yours, or (GET) you only inherit it |
404 bom_header_or_link_not_found |
POST /bom-lines |
bomHeaderId isn't linked to entity.styleId |
404 bom_line_link_not_found |
PUT /bom-lines/:id |
The line's header isn't linked to entity.styleId |
404 bom_lines_not_found |
POST /bom-lines/delete |
At least one requested line ID is missing — nothing was deleted |
404 bom_placement_not_found |
Line create & update | placementId doesn't exist (see GET /bom-placements) |
400 component_codes_not_found |
Line create & update | The component product has no product codes — add a code (e.g. ERP ID) first |
Header field limits: name ≤ 255 characters, description ≤ 4000, countryOfOrigin ≤ 100.
Gotchas Checklist
- IDs are not stable across writes. Any write through a product with
link.isOwner: falseduplicates the header and all lines with new IDs. Always re-read IDs from the write response (or re-fetch, for line deletes). - Check
link.isOwnerbefore writing if you need to predict whether your change affects the whole product family (owner) or forks a private copy (inherited). - Copy-on-write detaches permanently. Once a variant forks its BOM, later changes to the style's BOM never reach that variant. There is no re-attach API, and deleting the variant's copy does not restore the inherited link — inherited links are only auto-created when a BOM is first created on a parent, or when a new child product is created. A detached variant that deletes its copy simply ends up with no BOM. Fork deliberately.
styleIdon a BOM line = the component, not the owning product.GET /bom-lines?styleId=Xanswers "where is X used as a component?".- Boolean query filters accept only
trueorfalse(isArchived,isMainBom,isMainComponent) — any other value is rejected with a400. Omit the parameter to skip the filter. PUT /bom-lines/:idis a full replace of the line's optional fields — omitted fields are nulled.- Components must have at least one product code before they can be used in a BOM line.
- Child links are created asynchronously after
POST /bom-headerson a style/variant — allow a few seconds before expecting them on children. - Duplicated headers are renamed
"<name> (Copy)"(a header update with a new name uses your name instead). Don't rely on BOM names as identifiers. - List
limitis capped at 100.
Related Reading
- Materials Guide — material composition trees: the same link/inheritance model, but with different copy-on-write behavior
- Products Guide — Style → Variant → SKU lifecycle and field inheritance (
null= inherit) - API Reference — interactive endpoint and schema documentation, including
/bom-placements - All integration guides