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. The copied header and lines start with externalId: null - your externalIds stay on the originals (see Your own 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 / externalId 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.


Your own IDs (externalId)

Product IDs. Every product ID in this guide - styleId, link.styleId, entity.styleId, component.styleId - accepts either the Retraced ID or the externalId you set when creating the product. GET /bom-headers?styleId=DRS-2024-001 and GET /bom-headers?styleId=style_01K... are equivalent. See the Products Guide for the rules.

BOM headers, lines and placements carry an externalId of their own:

Copy-on-write never copies an externalId. When a variant or SKU edits a BOM it only inherits, the copy and all its lines start with externalId: null, and the originals keep theirs - the style and its other children still use them. So after a fork:

# Variant "Red" only inherits PLM-BOM-77, so renaming it through the variant forks a copy.
curl -X PUT "$BASE_URL/bom-headers/PLM-BOM-77" \
  -H "companyapikey: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "styleId": "SAP-STYLE-9-RED", "name": "T-Shirt Assembly (Red)", "isMainBom": true, "externalId": "PLM-BOM-77-RED" }'
# → data.id is the copy's new Retraced ID, data.externalId is "PLM-BOM-77-RED".
#   PLM-BOM-77 still identifies the style's BOM.

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",
    "externalId": "PLM-BOM-77",
    "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",
    "externalId": "PLM-BOM-77",
    "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).

bomHeaderId on its own cannot replace entity.styleId: one header is linked to the style and every variant/SKU inheriting it, each link with its own isOwner. The entity you pass is what decides whether the shared BOM is edited or forked for that product first.

quantity/weight are optional non-negative numbers. quantityUnit/weightUnit must be platform unit enum values (see the OpenAPI reference at https://publicapi.retraced.com/api/docs) and must be among the units of measure configured for the component's style type. Read the accepted units before you write: GET /style-types returns quantityUnits and weightUnits per type, and GET /bom-lines?include=entity returns them on entity.type for every existing line. A unit outside that list is rejected with 400 invalid_bom_quantity_unit / 400 invalid_bom_weight_unit, and the error message lists what is accepted. When the component's style type has no units configured, the platform-wide quantity and weight lists apply.

On PUT /bom-lines/:id, a unit that is already stored on the line is always accepted as long as component.styleId is unchanged — a line written before its style type was configured stays editable without having to change its unit.

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
400 invalid_bom_quantity_unit Line create & update quantityUnit is not one of the Quantity UoMs configured for the component's style type — the message lists the accepted units
409 external_id_already_exists Header, line & placement create; header & line update The externalId is already used by another BOM header, line or placement in your company - including the original of a BOM you are forking
400 invalid_bom_weight_unit Line create & update weightUnit is not one of the Weight UoMs configured for the component's style type — the message lists the accepted units

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 and externalId: null. Always re-read IDs from the write response (or re-fetch, for line deletes), and send externalId in the forking request if you want to tag the copy.
  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.