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).

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)─┘

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:

  1. The child's link to the shared header is deleted.
  2. 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.
  3. The child gets a new link to the copy with isOwner: true and version: 1.
  4. 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 the isMainComponent flag — 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 true or false. Pass isMainBom=false / isArchived=false to filter for the false value, or omit the parameter to skip the filter. Any other value (e.g. 1, yes) is rejected with a 400.

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"
      }
    }
  ]
}

⚠️ styleId means the component here, not the product. On a BOM line, styleId is the ID of the component product (the button, the fabric panel) that sits inside the BOM. Consequently the styleId query filter on GET /bom-lines filters 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


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:

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:

⚠️ 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 to null/false when 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.

Response (200): { "deletedLines": [...], "duplicatedHeaders": [], "orphanedHeaders": [], "errors": [] }

Two things to know about this endpoint:

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..."]
  }'

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.


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

  1. IDs are not stable across writes. Any write through a product with link.isOwner: false duplicates the header and all lines with new IDs. Always re-read IDs from the write response (or re-fetch, for line deletes).
  2. Check link.isOwner before writing if you need to predict whether your change affects the whole product family (owner) or forks a private copy (inherited).
  3. 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.
  4. styleId on a BOM line = the component, not the owning product. GET /bom-lines?styleId=X answers "where is X used as a component?".
  5. Boolean query filters accept only true or false (isArchived, isMainBom, isMainComponent) — any other value is rejected with a 400. Omit the parameter to skip the filter.
  6. PUT /bom-lines/:id is a full replace of the line's optional fields — omitted fields are nulled.
  7. Components must have at least one product code before they can be used in a BOM line.
  8. Child links are created asynchronously after POST /bom-headers on a style/variant — allow a few seconds before expecting them on children.
  9. 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.
  10. List limit is capped at 100.