---
title: "Upsert a product"
method: POST
path: "/public/v1/products"
tags: ["Product"]
---

# Upsert a product

`POST /public/v1/products`

Creates or updates a single product catalog entry through one endpoint. Omit `id` to create a new product (Distru assigns and returns its ID); pass an existing product's `id` to update it. An `id` that isn't valid, or one that doesn't belong to your company, returns a not-found error. On create you must supply at minimum `name`, `sku`, `category_id`, `vendor_id`, `unit_type_id`, `unit_price`, and `inventory_tracking_method`; your company settings can make more fields mandatory (for example a required description or a required product photo). `name` and `sku` must each be unique within your company.

Updates are sparse: only the fields you send are changed, and any field you omit keeps its stored value. The list- and map-valued fields replace wholesale *when you send them*, but omitting the field leaves the current value untouched. `tags` replaces the product's entire tag set when sent — any tag you leave out of the list is removed, and an empty array clears them all; omit `tags` to leave the current tags unchanged. `custom_data` likewise replaces the entire custom-field map when sent, and is left untouched when omitted. Menu placement is driven by `menu_visibility`: omit it to leave the product's current menu placement untouched; send DO_NOT_INCLUDE to remove it from all menus, INCLUDE_IN_ALL to place it on every menu, or INCLUDE_IN_SELECT with `menus` to set the exact set (that list replaces the product's full menu set — omit a menu to remove it). `menus` may only be sent alongside `menu_visibility`: it is required with INCLUDE_IN_SELECT and ignored with the other two modes, so sending `menus` without `menu_visibility`, or INCLUDE_IN_SELECT without `menus`, is rejected with a 400.

Some attributes are locked once established. `inventory_tracking_method` cannot be changed after it is first set — the only allowed transition is PRODUCT to BATCH. `unit_type_id` cannot be changed once the product has any inventory. Attempting either returns a validation error.

This endpoint writes only the product's catalog record. It does NOT create, move, reserve, or release inventory (add packages or batches separately), and it does NOT itself push the product to Metrc or BioTrack — products are local catalog data and only their inventory syncs to state traceability. For BioTrack companies the `category_id` you choose must be compatible with `inventory_tracking_method` (a PACKAGE-tracked product needs a category tied to a BioTrack inventory type; a non-package one needs a category that is not). Changing `menu_visibility` or `menus` changes which DistruCommerce menus and the Order Tracker surface the product. If the product is linked to a connected sales channel (LeafLink, or a POS such as Blaze/Dutchie/Treez), catalog and price edits made here propagate to that channel. The product's [bill_of_materials](#model-billofmaterials) is readable in the response but is NOT settable here — manage it through its own endpoints.

Required permission: `products_permissions_create` to create a new product, or `products_permissions_edit` (plus access to the product under team restrictions) to update an existing one.

## Request body

- object
  - `id` string — ID for this product. Omit it to create a new product — Distru assigns the ID. Provide an existing product's ID to update that product; an ID that doesn't exist returns a not-found error.
  - `inventory_tracking_method` 'PACKAGE' | 'BATCH' | 'PRODUCT' — How this product's inventory is tracked. Required on create. Once set it cannot be changed, with the single exception that a PRODUCT-tracked product may later be switched to BATCH. SCREAMING_CASE, one of:• PACKAGE: Inventory is defined by individual packages (the tracking method used for state-compliance/Metrc packages).• PRODUCT: Not grouped in any manner. Inventory is a simple running quantity you add to or remove from as you transact.• BATCH: Grouped into batches that share common traits such as expiration dates and test results.
  - `sku` string — Stock Keeping Unit (SKU). Required on create and must be unique within your company.
  - `name` string — Product name. Required on create and must be unique within your company. Cannot contain the characters `:`, `[`, or `]`, or other Metrc-disallowed special characters.
  - `vendor_id` string — ID of the company relationship for the vendor company that supplies this product. This is the company-relationship ID, not the raw company ID. Required on create.
  - `category_id` string — ID of the product's category. Required on create. For BioTrack companies this must be compatible with `inventory_tracking_method`: a PACKAGE-tracked product needs a category tied to a BioTrack inventory type, a non-package one needs a category that is not.
  - `external_name` string — Customer-facing name shown on DistruCommerce menus and the Order Tracker. Falls back to `name` when left blank or omitted, so the response's `external_name` is never null.
  - `unit_type_id` string — ID of the product's unit type. Required on create. Cannot be changed once the product has any inventory.
  - `unit_price` number — Sale price of the product per unit, as a decimal. Required on create. Must be non-negative (>= 0).
  - `description` string — Plain-text description of the product. If you send `description` without `description_markdown`, the markdown description is overwritten with this plain text. If you send `description_markdown` without `description`, the request is rejected — the two must be provided together (or neither). Some companies require a description; those companies reject a create/update that omits both.
  - `description_markdown` string — Markdown-formatted description. Must be provided together with `description`. Only italic, bold, strikethrough, and links are supported by the display — other markdown may render unpredictably.
  - `upc` string — Universal Product Code (UPC) for this product
  - `subcategory_id` string — ID of the product's subcategory. When provided it must be a child of the category given in `category_id`.
  - `group_id` string — ID of the product's group.
  - `brand_id` string — ID of the company relationship for the brand company associated with this product. This is the company-relationship ID, not the raw company ID.
  - `quantity_available_threshold_min` number — Minimum quantity you want to keep in stock. When the product's available quantity dips below this number it is automatically included in scheduled Low Inventory Reports. Optional; omit to set no low threshold. When both thresholds are set, `quantity_available_threshold_max` must be greater than this value.
  - `quantity_available_threshold_max` number — Maximum quantity you want to keep in stock. When the product's available quantity exceeds this number it is automatically included in scheduled Inventory Reports. Optional; omit to set no high threshold. When both thresholds are set, this must be greater than `quantity_available_threshold_min`.
  - `units_per_case` number — Number of units in a case of the product. When provided must be greater than 0.
  - `unit_cost` number — Cost of the product per unit, as a decimal. When provided must be non-negative (>= 0).
  - `msrp` number — The Manufacturer's Suggested Retail Price (MSRP) of the product per unit. If you have POS integrations enabled in Distru, this may be synced to your POS
  - `wholesale_unit_price` number — The wholesale price of the product per unit. When provided must be non-negative (>= 0).
  - `treez_wholesale_price` number — The Treez wholesale price of the product per unit, as a decimal. When your company has the Treez wholesale price setting enabled, this value is used as the default price on order items for this product. Omit to leave an existing value unchanged; send null to clear it.
  - `is_featured` boolean — Whether the product is featured. Featured products are shown at the top of menus. Defaults to false when omitted on create.
  - `strain_id` string — ID of the strain associated with the product.
  - `owner_id` string — ID of the user considered the owner of the product. Must be a user the authenticated caller is allowed to assign as owner under their team restrictions.
  - `leaflink_product_id` integer — The LeafLink product ID this product is linked to, used to match LeafLink orders to this product. Must be unique within your account — sending a value already linked to another product is rejected. Omit to leave an existing link unchanged; send null to unlink.
  - `is_inactive` boolean — Whether the product is inactive (hidden from normal use). Defaults to false when omitted on create. Can be flipped back to active at any time.
  - `total_cannabinoid_unit` string — Unit for this product's THC/CBD content. One of PERCENT or MG (SCREAMING_CASE). Required whenever `total_thc` or `total_cbd` is provided.
  - `total_thc` string — THC content of the product, expressed in the unit given by `total_cannabinoid_unit` (which must be provided alongside it). Must be non-negative (>= 0); when `total_cannabinoid_unit` is PERCENT it cannot exceed 100.
  - `total_cbd` string — CBD content of the product, expressed in the unit given by `total_cannabinoid_unit` (which must be provided alongside it). Must be non-negative (>= 0); when `total_cannabinoid_unit` is PERCENT it cannot exceed 100.
  - `menu_visibility` 'DO_NOT_INCLUDE' | 'INCLUDE_IN_ALL' | 'INCLUDE_IN_SELECT' — Controls which menus (if any) the product appears on. Defaults to DO_NOT_INCLUDE when omitted on create; on update, omit it to leave the product's current menu placement untouched. SCREAMING_CASE, one of:• DO_NOT_INCLUDE: The product appears on no menus; any `menus` you send are ignored.• INCLUDE_IN_ALL: The product appears on every menu in your company; `menus` is ignored.• INCLUDE_IN_SELECT: The product appears only on the menus listed in `menus`, which fully replaces its current menu set. `menus` is required in this mode. Required whenever `menus` is provided — sending `menus` without `menu_visibility` is rejected.
  - `unit_net_weight` number — Net weight/volume of the product per unit. When provided must be greater than 0 and requires `unit_net_weight_and_serving_size_unit_type_id` to be set.
  - `unit_serving_size` number — Serving size of the product per unit. When provided must be greater than 0, cannot exceed `unit_net_weight`, and requires `unit_net_weight_and_serving_size_unit_type_id` to be set.
  - `unit_net_weight_and_serving_size_unit_type_id` string — ID of the unit type that `unit_net_weight` and `unit_serving_size` are measured in. Required whenever either of those is set. Applies only to count-based products; leave null otherwise. Once set, changing the product away from a count/'Unit' category is rejected.
  - `gross_weight` number — Gross weight of the product. When provided must be greater than 0 and must be set together with `gross_weight_unit_type_id` (both present or both absent).
  - `gross_weight_unit_type_id` string — ID of the weight unit type `gross_weight` is measured in. Must be a weight-based unit type that Metrc supports, and must be set together with `gross_weight` (both present or both absent).
  - `tags` string[] — Tag IDs to associate with the product. When sent, the list replaces the product's complete tag set — any tag left out is removed, and an empty array clears all tags. Omit `tags` on update to leave the current tags unchanged.
  - `menus` string[] — Menu IDs the product should appear on. Only used when `menu_visibility` is INCLUDE_IN_SELECT — required in that mode, ignored with DO_NOT_INCLUDE and INCLUDE_IN_ALL. When applied, this list becomes the product's complete menu set, so omit a menu from the list to remove the product from it. Must be sent together with `menu_visibility`: sending `menus` without `menu_visibility` is rejected, as is INCLUDE_IN_SELECT without `menus`. Omitting `menu_visibility` entirely leaves the current menu placement untouched.
  - `custom_data` object — A map of custom field IDs to their values. Use GET /public/v1/custom-fields?parent_object=product to retrieve available custom fields, their IDs, and their types. The value format depends on the field's type: a text field takes a string, a date field takes a full ISO8601 datetime, and a checkbox field takes an array of its selected options. On update, omit `custom_data` to leave the stored map unchanged; when sent it replaces the entire map, so include every field you want to keep.

## Response `200`

A single product

## Other responses

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

---

[API](https://skmtc.dev/distru/apis/distru-api.md) · [All operations](https://skmtc.dev/distru/apis/distru-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/distru/distru-api/revisions/f8930b2c2924/schema)
