---
title: "Upsert a product bill of materials"
method: POST
path: "/public/v1/products/{id}/bill-of-materials"
tags: ["Product"]
---

# Upsert a product bill of materials

`POST /public/v1/products/{id}/bill-of-materials`

Creates or updates the [bill of materials](#model-billofmaterials) of a product: the inputs and additional costs it takes to make one unit of it. A product has at most one bill, so there is no bill id to pass: the first call creates it, later calls update it. Returns the bill exactly as [GET /public/v1/products/{id}](#get-a-product) shows it.

Creating needs at least one input (in `product_inputs` or `dynamic_inputs`); `name` defaults to the product's name. Updates are sparse: a field you omit keeps its stored value, and `description: null` clears the description. `product_inputs`, `dynamic_inputs` and `costs` each replace their whole list when sent and are left untouched when omitted. Within a sent list, an entry with the `id` of a stored row patches that row with only the fields it carries; an entry without `id` is added; a stored row whose `id` you leave out is deleted. The two input lists together must keep at least one input: to remove the bill, use [DELETE /public/v1/products/{id}/bill-of-materials](#delete-a-product-bill-of-materials).

A product input names a product to consume; its `quantity` is in that product's unit type. Inactive products can be inputs; deleted products and the product itself cannot, and a product that is not package-tracked cannot take a package-tracked input. A dynamic input takes any product that meets every condition list you fill (any entry within a list) and whose unit type measures the same thing as `unit_type_id` (count, weight or volume); its `quantity` is in `unit_type_id` and is converted to the matching product's unit type. Every quantity is per one unit of this product. For BioTrack companies the inputs must also be valid ingredients for the product's BioTrack inventory type.

The bill is a recipe only. It pre-fills the inputs and additional costs of new manufacturing assemblies created in the Distru web app; assemblies created through the API do not read it, and existing assemblies keep their own inputs. It creates, reserves or consumes no inventory, never changes the product's cost, and syncs nothing to Metrc or BioTrack. The operation is all-or-nothing: if any entry is rejected, nothing is changed.

Requires the Core module (otherwise 403). Required permission: `products_permissions_edit` (plus access to the product under team restrictions).

## Path parameters

- `id` string, required

## Request body

- object
  - `name` string — Name of the bill. Defaults to the product's name on create; omit to keep the stored name. Cannot be null.
  - `description` string — Free-text description. Omit to keep the stored one; send null to clear it.
  - `product_inputs` BillOfMaterialsProductInputRequest[] — The inputs that name a specific product, each following [BillOfMaterialsProductInputRequest](#model-billofmaterialsproductinputrequest). Omit to keep the stored product inputs. When sent, it is the complete set: a stored product input whose `id` you leave out is deleted, an entry with a stored `id` is patched, an entry without `id` is added. Send `[]` to remove every product input, as long as `dynamic_inputs` keeps at least one.
    - `id` string — ID of a stored product input to patch; only the fields you send change. Omit to add a new input. An ID that isn't a product input of this bill returns 400.
    - `product_id` string — ID of the product to consume. Required on a new input. Inactive products are allowed; deleted products and the bill's own product are refused.
    - `quantity` number — How much of the product one unit of this product takes, in the input product's unit type, as a decimal greater than 0. Required on a new input.
  - `dynamic_inputs` BillOfMaterialsDynamicInputRequest[] — The inputs that select products by attribute, each following [BillOfMaterialsDynamicInputRequest](#model-billofmaterialsdynamicinputrequest). Omit to keep the stored dynamic inputs. When sent, it is the complete set, with the same `id` rules as `product_inputs`. Send `[]` to remove every dynamic input, as long as `product_inputs` keeps at least one.
    - `id` string — ID of a stored dynamic input to patch; only the fields you send change. Omit to add a new input. An ID that isn't a dynamic input of this bill returns 400.
    - `product_category_ids` string[] — IDs of product categories; a matching product is in one of them.
    - `product_group_ids` string[] — IDs of product groups; a matching product is in one of them.
    - `product_subcategory_ids` string[] — IDs of product subcategories; a matching product is in one of them. Each must belong to a category in `product_category_ids`.
    - `quantity` number — How much of a matching product one unit of this product takes, in `unit_type_id`, as a decimal greater than 0; converted to the matching product's unit type. Required on a new input.
    - `strain_ids` string[] — IDs of strains; a matching product has one of them.
    - `tag_ids` string[] — IDs of product tags; a matching product has at least one of them.
    - `unit_type_id` string — ID of the unit type `quantity` is in. Required on a new input.
  - `costs` BillOfMaterialsCostRequest[] — The additional costs, each following [BillOfMaterialsCostRequest](#model-billofmaterialscostrequest). They only pre-fill the additional costs of new assemblies in the web app. Omit to keep the stored costs. When sent, it is the complete set, with the same `id` rules as `product_inputs`; send `[]` to remove every cost.
    - `cost_type_id` string — ID of the [cost type](#model-costtype). Required on a new cost.
    - `description` string — Free-text description; null clears it.
    - `id` string — ID of a stored cost to patch; only the fields you send change. Omit to add a new cost. An ID that isn't a cost of this bill returns 400.
    - `quantity` number — How many units of the cost type one unit of this product takes, as a decimal greater than 0. Required on a new cost.

## Response `200`

The product's bill of materials

## Other responses

- `400` — Invalid parameters
- `401` — Missing or invalid API token
- `403` — The API token lacks the required permission
- `404` — Not Found

## Changes

- **2026-10-07** `e8639f7dafed` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/distru/apis/distru-api/changes/public/v1/products/:id/bill-of-materials/post.md)

---

[API](https://skmtc.dev/distru/apis/distru-api.md) · [All operations](https://skmtc.dev/distru/apis/distru-api/llms.txt) · [OpenAPI document](https://skmtc.dev/distru/apis/distru-api/revisions/e8639f7dafed?raw)
