Materials Guide — Headers, Lines & Inheritance
How to read and write material compositions through the Retraced public API, and — more importantly — how material inheritance and copy-on-write work. Materials share the BOM's link-based inheritance model but with one extra twist: copy-on-write behaves differently for header updates and line writes. If you only read one section, read The Mental Model.
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/material-headers?styleId=$STYLE_ID" \
-H "companyapikey: $API_KEY"
All responses use the standard envelope:
{
"metadata": { "success": true },
"data": { }
}
The Mental Model
Data model
A material composition is not stored per product. It is stored once and linked to products:
Material Header (name, description) ── the composition, stored once
│
├── Material Links (styleId, isOwner, isMainMaterial)
│ ── one link per product that "has" this composition
│
└── Material Lines (rawMaterialId, percentage, parentId, countriesOfOrigin, ...)
── a TREE of material entries
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).
Unlike BOM lines (a flat component list), material lines form a tree:
Header "Shell Fabric"
├─ Cotton 80% (parentId: null → root line, hierarchy 0)
│ ├─ Organic Cotton 60% (parentId: <cotton-line-id>, hierarchy 1)
│ └─ Recycled Cotton 40%
└─ Polyester 20% (parentId: null)
Each line carries:
| Field | Meaning |
|---|---|
rawMaterialId |
Required. ID of a material from the Retraced materials catalog |
percentage |
0–100. Share of this material among its siblings |
parentId |
null = root line; otherwise the parent line's ID |
hierarchy |
Tree depth, 0-based (informational — derived at create time) |
position |
Sibling ordering; auto-assigned in increments of 100 (100, 200, …) |
weights / weightUnit |
Optional. Non-negative number + free-form unit string (e.g. "GSM") |
countriesOfOrigin |
Optional array of 2-letter ISO country codes (e.g. ["PT", "TR"]) |
Two tree-wide rules are enforced on every write:
- Percentages: siblings at each level must sum to ≤ 100 (it does not have to equal 100). Exceeding 100 fails with
400 percentage_constraint_violation. - Countries: a child's
countriesOfOriginmust be a subset of its parent's. Violations fail with400 countries_of_origin_constraint_violation. A parent with no countries cannot have children with countries.
Inheritance = shared links
When you create a material header 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)─┼──► Header "Shell Fabric"
│ └─ SKU "Red / M" ──link (isOwner: false)─┤
└─ Variant "Blue" ──link (isOwner: false)─┘
- The product that created the header 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. - Edits made by the owner are instantly visible to every inheriting child.
Note: Child links are created asynchronously. After creating a header on a style, the variant/SKU links appear within a few seconds — don't expect them synchronously in the create response.
Copy-on-write: TWO different behaviors
For BOMs, every write through an inherited link forks the whole structure. Materials are not that uniform — header updates and line writes behave differently. Both behaviors below are exactly what the API does today; plan your integration around them.
Behavior 1 — line writes (POST /material-lines, PUT /material-lines/:id) through an inherited link: full fork + detach.
- The header is duplicated — new header ID, name suffixed with
" (Copy)"(always, even if you didn't touch the name), all lines copied with new line IDs (parent/child relationships remapped). - The product gets a new link to the copy with
isOwner: true, and its old inherited link is deleted. - Your create/update lands in the copy — the response's
materialHeaderId(and line ID, for updates) belong to the new header. - The product is now permanently detached: future changes to the parent's composition no longer reach it. Its own children are not moved over — 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
⚠️ Never cache material header or line IDs across writes — always take the IDs from the response of your last write.
Behavior 2 — header updates (PUT /material-headers/:id) through an inherited link: header-only duplicate, NO detach.
Unlike line writes, this duplicates only the header record — the lines are not copied — and the old inherited link is not removed. The product ends up linked to two headers: the original shared one (with its lines) and a new, empty owned copy:
Before After variant PUTs the inherited header
Style ──(owner)───► Header A Style ──(owner)───► Header A (lines intact)
Variant ──(inherit)─► Header A Variant ──(inherit)─► Header A ← still there
Variant ──(owner)───► Header A (Copy) ← NEW, ZERO lines
⚠️ Any field triggers this — including an
isMainMaterial-only change. There is no change detection on this path, so even re-sending the current values through an inherited link creates another empty copy. The"(Copy)"suffix is applied when your submitted name equals the current name; if you send a new name, your name is used. Practical rule: neverPUT /material-headers/:idthrough a product withlink.isOwner: false. If a child product needs its own composition, fork it with a line write (Behavior 1), or create a fresh header on the child.
What triggers copy-on-write — and what doesn't
Operation via a product with an inherited link (isOwner: false) |
Effect |
|---|---|
POST /material-lines (add a line) |
Full fork + detach — composition duplicated, new line goes into the copy |
PUT /material-lines/:id (edit a line) |
Full fork + detach — the edit lands on the copied line |
PUT /material-lines/:id with no actual changes |
No copy — short-circuits and returns existing data |
POST /material-lines/delete |
Fork, but no detach — see Deleting lines for this endpoint's surprising semantics |
PUT /material-headers/:id (any field, even isMainMaterial only) |
Header-only duplicate, no lines, no detach (Behavior 2) |
POST /material-headers/delete |
No copy — the product detaches from the composition (link removed; shared header 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 /material-headers?styleId=... |
List compositions linked to a product (any level) |
GET /material-headers/:id |
Get one header (resolves via its owner link only) |
POST /material-headers |
Create a composition on a product |
PUT /material-headers/:id |
Rename / set main material (see Behavior 2 above) |
POST /material-headers/delete |
Delete compositions / detach inherited ones |
GET /material-lines?materialHeaderId=... |
List the lines of a composition |
POST /material-lines |
Add a material line (root or child) |
PUT /material-lines/:id |
Edit a line (subject to copy-on-write) |
POST /material-lines/delete |
Remove lines (see Deleting lines) |
Reading Material Data
Step 1 — list a product's material headers
styleId is required and can be a style, variant, or SKU ID.
⚠️
styleIdis the Retraced internal product ID — not one of yourcodes[]values. It is theidfield returned by the styles endpoint (e.g.style_01KMZ…,variant_…,sku_…), not your ERP number or EAN. Material responses don't echocodes[], so if you only have your own code, resolve it to the Retracedidfirst viaGET /styles?nameOrCode=<your-code>(or?codeId=…&codeValue=…), then pass thatidhere.
curl "$BASE_URL/material-headers?styleId=$STYLE_ID" \
-H "companyapikey: $API_KEY"
Response (200):
{
"metadata": {
"success": true,
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
},
"data": [
{
"id": "mtrialhdr_01KTS...",
"name": "Shell Fabric",
"forCompanyId": "company-123",
"description": null,
"link": {
"styleId": "style-123",
"isMainMaterial": true,
"isOwner": true
},
"createdAt": "2026-06-10T08:00:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedAt": "2026-06-10T08:00:00.000Z",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123"
}
]
}
Read link.isOwner to know whether this product owns the composition or inherits it — that tells you whether your next write will hit the shared record or trigger copy-on-write. isMainMaterial is also a per-link value (see Main Material).
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 |
name, styleId, createdAt, updatedAt |
order |
string | No | desc |
asc, desc |
name |
string | No | — | Substring match, case-insensitive |
description |
string | No | — | Substring match, case-insensitive |
isMainMaterial |
boolean | No | — | See boolean gotcha below |
isOwner |
boolean | No | — | See boolean gotcha below |
Boolean filters accept
trueorfalse. PassisMainMaterial=false/isOwner=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 composition's lines
MATERIAL_HEADER_ID="mtrialhdr_01KTS..."
curl "$BASE_URL/material-lines?materialHeaderId=$MATERIAL_HEADER_ID&include=rawMaterials,countriesOfOrigin" \
-H "companyapikey: $API_KEY"
Response (200):
{
"metadata": {
"success": true,
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
},
"data": [
{
"id": "mtrialln_01KTS...",
"materialHeaderId": "mtrialhdr_01KTS...",
"rawMaterialId": "COTTON_BALE",
"percentage": 80,
"weights": 120,
"weightUnit": "GSM",
"parentId": null,
"hierarchy": 0,
"position": 100,
"countriesOfOrigin": ["PT", "TR"],
"createdAt": "2026-06-10T08:05:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedAt": "2026-06-10T08:05:00.000Z",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123",
"rawMaterial": {
"id": "COTTON_BALE",
"name": "Cotton bale",
"description": "After cotton is harvested, ...",
"category": "NATURAL_MATERIAL",
"isRecycled": false,
"isSynthetic": false
},
"countries": [
{
"countryCode": "PT",
"countryName": "Portugal",
"countryNameLocale": "Portugal",
"region": "Europe",
"currencyCode": "EUR",
"currencyName": "Euro"
}
]
}
]
}
Rebuild the tree client-side from parentId (root lines have parentId: null); order siblings by position.
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
materialHeaderId |
string | No | — | Lines of a specific composition (the usual entry point) |
rawMaterialId |
string | No | — | Filter by catalog material |
parentId |
string | No | — | Filter by parent line (direct children only) |
countriesOfOrigin |
string | No | — | Substring match against the stored country list |
include |
string | No | — | Comma-separated: rawMaterials (catalog details as rawMaterial), countriesOfOrigin (country details as countries) |
page |
number | No | 1 | Page number |
limit |
number | No | 20 | Max 100 |
sort |
string | No | createdAt |
materialHeaderId, rawMaterialId, percentage, hierarchy, position, createdAt, updatedAt |
order |
string | No | desc |
asc, desc |
Get a single header
GET /material-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 material_header_not_found. When in doubt, use the list endpoint with styleId, which returns both owned and inherited compositions.
curl "$BASE_URL/material-headers/$MATERIAL_HEADER_ID" \
-H "companyapikey: $API_KEY"
Creating a Composition
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/material-headers" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Shell Fabric",
"description": "Main body composition",
"link": {
"styleId": "style-123",
"isMainMaterial": true
}
}'
Response (201): the header with its (owner) link echoed back:
{
"metadata": { "success": true },
"data": {
"id": "mtrialhdr_01KTS...",
"name": "Shell Fabric",
"forCompanyId": "company-123",
"description": "Main body composition",
"link": { "styleId": "style-123", "isMainMaterial": true },
"createdAt": "2026-06-10T08:00:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedAt": "2026-06-10T08:00:00.000Z",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123"
}
}
The creating product owns the new header. Child links (isOwner: false) are created asynchronously in the background.
MATERIAL_HEADER_ID="mtrialhdr_01KTS..." # from the response
Add lines — roots first, then children
Create the root lines (parentId: null), then create children using the IDs from the responses:
# Root: Cotton 80%, from Portugal and Turkey
curl -X POST "$BASE_URL/material-lines" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"materialHeaderId": "'$MATERIAL_HEADER_ID'",
"parentId": null,
"rawMaterialId": "COTTON_BALE",
"percentage": 80,
"weights": 120,
"weightUnit": "GSM",
"countriesOfOrigin": ["PT", "TR"]
}'
Response (201):
{
"metadata": { "success": true },
"data": {
"id": "mtrialln_01KTS...",
"materialHeaderId": "mtrialhdr_01KTS...",
"rawMaterialId": "COTTON_BALE",
"percentage": 80,
"weights": 120,
"weightUnit": "GSM",
"parentId": null,
"hierarchy": 0,
"position": 100,
"countriesOfOrigin": ["PT", "TR"],
"createdAt": "2026-06-10T08:05:00.000Z",
"createdByUserId": "user-1",
"createdByCompanyId": "company-123",
"updatedAt": "2026-06-10T08:05:00.000Z",
"updatedByUserId": "user-1",
"updatedByCompanyId": "company-123"
}
}
ROOT_LINE_ID="mtrialln_01KTS..." # from the response
# Child: Organic Cotton, 60% of the Cotton share, Portugal only (subset of parent!)
curl -X POST "$BASE_URL/material-lines" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"materialHeaderId": "'$MATERIAL_HEADER_ID'",
"parentId": "'$ROOT_LINE_ID'",
"rawMaterialId": "ORGANIC_COTTON",
"percentage": 60,
"countriesOfOrigin": ["PT"]
}'
Field notes:
styleIdis the product through which you are writing (must be linked tomaterialHeaderId). ItsisOwnerflag decides whether copy-on-write fires — if it only inherits the composition, the response'smaterialHeaderIdis a new header (see The Mental Model).parentIdis required in the body — passnullexplicitly for a root line.percentageis relative to siblings, so the 60% above means 60% of the Cotton portion. Sibling sums at each level must stay ≤ 100:
{
"metadata": { "success": false },
"statusCode": 400,
"code": "percentage_constraint_violation",
"message": "Total percentage for material header would exceed 100%. Current total: 80%, Adding: 30%, Total would be: 110%"
}
- A child's countries must be a subset of its parent's:
{
"metadata": { "success": false },
"statusCode": 400,
"code": "countries_of_origin_constraint_violation",
"message": "Countries of origin [US] are not present in parent material line. Parent countries: [PT, TR]"
}
rawMaterialIdmust come from the Retraced materials catalog. There is no public catalog list endpoint — discover IDs viainclude=rawMaterialson lines you can already see (in your account or another product), or ask your Retraced contact for the catalog. Note the ID is not validated at create time; a typo simply produces a line whoserawMaterialresolves to nothing.
Modifying a Composition
The same request behaves differently depending on the isOwner flag of the link between the product you pass and the header — check it first via GET /material-headers?styleId=....
Update the header
curl -X PUT "$BASE_URL/material-headers/$MATERIAL_HEADER_ID" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Shell Fabric v2",
"description": "Updated",
"link": {
"styleId": "style-123",
"isMainMaterial": true
}
}'
- Owner: header updated in place — all inheriting children see the new name/description. Setting
isMainMaterial: truedemotes the product's previous main material. - Inherited: Behavior 2 — a new empty header copy is created for this product and the inherited link stays. Avoid this path (see The Mental Model).
Edit a line
LINE_ID="mtrialln_01KTS..."
curl -X PUT "$BASE_URL/material-lines/$LINE_ID" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"materialHeaderId": "'$MATERIAL_HEADER_ID'",
"parentId": "'$ROOT_LINE_ID'",
"rawMaterialId": "ORGANIC_COTTON",
"percentage": 65,
"countriesOfOrigin": ["PT"]
}'
- Owner: line updated in place.
- Inherited: the entire composition is duplicated for
styleId, the old inherited link is removed, and your edit lands on the copied line. The response contains the new line ID and newmaterialHeaderId— discard the old ones. - If nothing actually changed, the call short-circuits and returns the existing line (no duplication).
⚠️
PUT /material-lines/:idis a full replace — omitted fields are cleared, and omittingparentIdMOVES the line. Omittedweights,weightUnit, andcountriesOfOriginare set tonull. OmittingparentIddoesn't keep the current parent — it re-roots the line to the top level (where it must then satisfy the root-level percentage sum). Always send the line's full desired state, including its currentparentId. (positionandhierarchyare the exception: they are preserved when omitted — which also means a re-rooted line keeps its old, now-misleadinghierarchyvalue.)
- Changing a root line's
countriesOfOriginis validated against its children: removing a country that a child still uses fails with400 parent_countries_constraint_violation. This guard applies to root lines only — when restructuring mid-tree, update children before narrowing their parents' countries.
Delete lines
curl -X POST "$BASE_URL/material-lines/delete" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"materialLineIds": ["mtrialln_01KTS..."]
}'
Up to 100 IDs per call. styleId is the product through which you are deleting.
Response (200): { "deletedLines": [...], "duplicatedHeaders": [], "orphanedHeaders": [], "errors": [...] }
- Owner: lines and their direct children are hard-deleted from the shared composition.
- Inherited: ⚠️ this endpoint does not follow the fork-and-detach pattern you'd expect. What actually happens: a forked copy of the composition (minus the deleted lines) is created and linked to your product with
isOwner: true— but the requested lines are also deleted from the original shared header, affecting the owner and all other inheriting products, and your old inherited link is not removed (the product stays linked to both headers). Do not delete lines through a product withlink.isOwner: falseunless you intend to modify the shared composition for everyone.
Three more things to know about this endpoint:
duplicatedHeadersandorphanedHeadersare always empty, even when a fork was created. To find a forked header, re-fetchGET /material-headers?styleId=....- Invalid IDs are reported per-item in
errors({ materialLineId, message }) without failing the whole request — valid IDs in the same call are still deleted. - The cascade is one level deep. Deleting a line removes the line and its direct children, but grandchildren are left in place with a dangling
parentId. On trees deeper than two levels, delete bottom-up (deepest lines first).
Delete / detach a composition header
curl -X POST "$BASE_URL/material-headers/delete" \
-H "companyapikey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"styleId": "style-123",
"materialHeaderIds": ["mtrialhdr_01KTS..."]
}'
- Through the owner: the header and all its lines are deleted, and every inherited child link is removed in the same operation (synchronously). The composition is gone for the whole product family.
- Through an inheriting child: the child detaches — its link and its own descendants' links are removed (detaching a variant also detaches that variant's SKUs). The parent and siblings keep the composition. This is the safe way for a child to drop an inherited composition.
Response (200): { "deletedHeaders": [...], "deletedLinks": [...], "errors": [...] } — per-ID failures are reported in errors without failing the whole request.
Main Material
isMainMaterial lives on the link, so each product in the hierarchy designates its own main material independently.
- One main material per product. Setting
isMainMaterial: true(on create, or on a PUT through the owner link) automatically demotes the product's previous main material. - Changing it on a style/variant propagates to its children's links asynchronously.
- ⚠️ Do not toggle it through an inherited link.
isMainMaterialis per-link, butPUT /material-headers/:idthrough an inherited link still triggers the header-only duplicate (Behavior 2) — and the automatic demotion of the previous main does not run on that path. Toggle it through the owner, or set it when creating the child's own composition.
Error Reference
| Status / code | Where | Meaning |
|---|---|---|
401 |
All endpoints | Missing or invalid companyapikey |
404 entity_not_found |
Header create & update | link.styleId doesn't exist in your catalog |
404 material_header_not_found |
GET/PUT /material-headers/:id, line create/update |
Header doesn't exist, isn't yours, (GET) you only inherit it, or it isn't linked to the styleId you passed |
404 material_line_not_found |
PUT /material-lines/:id |
Line doesn't exist or doesn't belong to the header/product you passed |
404 parent_line_not_found |
Line create & update | parentId doesn't refer to a line in the same composition |
400 percentage_constraint_violation |
Line create & update | Sibling percentages would exceed 100 at that tree level |
400 countries_of_origin_constraint_violation |
Line create & update | Line's countries are not a subset of its parent's |
400 parent_countries_constraint_violation |
Root-line update | Removing a country that a child line still uses |
400 invalid_parent_id / 400 invalid_line_id |
Line create / update via inherited link | The referenced line could not be mapped into the forked copy |
404 original_material_header_not_found |
Writes via inherited link | The shared header disappeared mid-fork |
Field limits: name ≤ 255 characters, description ≤ 4000, weightUnit ≤ 255, percentage 0–100, weights ≥ 0, country codes exactly 2 characters, materialLineIds ≤ 100 per delete call.
Gotchas Checklist
- IDs are not stable across line writes through inherited links. The fork renames the header to
"<name> (Copy)"and re-IDs the header and every line. Always re-read IDs from the write response. - Check
link.isOwnerbefore writing — it decides whether your change hits the whole product family (owner) or behaves per the trigger table (inherited). - Never
PUT /material-headers/:idthrough an inherited link — any field, evenisMainMaterialalone, creates an extra empty header copy without detaching. - Never
POST /material-lines/deletethrough an inherited link — it deletes the lines from the shared composition (visible to the owner and all siblings) while also creating a fork you'll discover only by re-fetching. Detach withPOST /material-headers/deleteor fork first with a line edit. PUT /material-lines/:idis a full replace. Omitted optional fields are nulled, and an omittedparentIdre-roots the line. Always resend the complete line.- Copy-on-write (line writes) detaches permanently. There is no re-attach API; deleting the fork does not restore the inherited link. Fork deliberately.
- Percentages are per-level and must sum to ≤ 100, not exactly 100. The API never checks completeness — your integration should, if you need full compositions.
- Child countries ⊆ parent countries, and a parent without countries cannot have children with countries. Build trees top-down with the widest country set at the root.
rawMaterialIdis not validated — a typo creates a line pointing at nothing. Verify withinclude=rawMaterialsafter writing.- Line deletion cascades one level only — delete bottom-up on deep trees, or grandchildren are left with dangling
parentIds. - Child links are created asynchronously after
POST /material-headerson a style/variant — allow a few seconds before expecting them on children. - Boolean query filters accept only
trueorfalse(isMainMaterial,isOwner) — any other value is rejected with a400. Omit the parameter to skip the filter. - List
limitis capped at 100.
Related Reading
- BOM Guide — Bills of Materials: the same link/inheritance model with uniform copy-on-write
- Products Guide — Style → Variant → SKU lifecycle and field inheritance (
null= inherit) - API Reference — interactive endpoint and schema documentation
- All integration guides