In short
kind: material for raw materials, packaging and miscellaneous, recipe for recipes, sub-recipes and finished goods.material and recipe is populated; the other is null.allergens, tag_ids, material and recipe null. Fetch one product to read them.allergens holds only what was declared, never what a recipe's components carry.Products are one resource with one id space, but they hold two different kinds of thing that carry different fields. Getting this wrong produces bugs in your system rather than errors from ours.
product_type has six values that fall into two families. Every product also carries kind, either material or recipe, so you never have to hold that mapping yourself. The raw product_type is returned too, because the values mean different things to you even inside one family.
| Family | kind | product_type values |
|---|---|---|
| Material | material | RAW_MATERIAL, PACKAGING, MISCELLANEOUS |
| Recipe | recipe | RECIPE, SUB_RECIPE, FINISHED_GOOD |
GET /products/{product_id} returns two nullable objects, material and recipe. Exactly one is populated, decided by kind; the other is null. The table below is generated from the published schemas.
| Field | Material | Recipe |
|---|---|---|
| product_type | RAW_MATERIAL, PACKAGING, MISCELLANEOUS | RECIPE, SUB_RECIPE, FINISHED_GOOD |
| kind | material | recipe |
| material.banned_country_ids | Yes | No, material is null |
material.food_idThe nutrition source. Fetch with GET /foods/{food_id}. | Yes | No, material is null |
| material.substitute_product_ids | Yes | No, material is null |
| material.supplier_ids | Yes | No, material is null |
| recipe.byproduct_product_ids | No, recipe is null | Yes |
| recipe.ingredients_enThe generated ingredient declaration. | No, recipe is null | Yes |
| recipe.ingredients_fr | No, recipe is null | Yes |
| recipe.portion_in_grams | No, recipe is null | Yes |
| recipe.qc_checkpoint_ids | No, recipe is null | Yes |
| recipe.recipe_grams_yield | No, recipe is null | Yes |
| Allergensallergens[].allergen_id | Yes | Yes |
| Tagstag_ids | Yes | Yes |
| Units of measureunit_of_measure_id | Yes | Yes |
| Product lotslinked by product_id | Yes | Yes |
material.food_id: the nutrition source. Fetch it with GET /foods/{food_id}.material.supplier_ids: the suppliers you buy it from.material.substitute_product_ids: products that can replace it.material.banned_country_ids: countries it must not come from.recipe.qc_checkpoint_ids: the quality checkpoints the recipe carries.recipe.byproduct_product_ids: products made alongside it.recipe.ingredients_en and recipe.ingredients_fr: the generated ingredient declaration.recipe.portion_in_grams and recipe.recipe_grams_yield.allergens lists the allergens recorded against a product, each with its allergen_id and a source. In v1 every entry is declared: recorded directly against this product. v1 does not roll allergens up from a recipe's components, so a finished good's list shows only what was declared on it. Do not present it as a complete allergen statement.
GET /products leaves allergens, tag_ids, material and recipe null: here null means not loaded, not none. Fetch a single product to read them.
price, cost and operations_cost are separate figures, each per inventory unit, and are never summed for you. All three, and the tax flags tax1 and tax2, need cost:read; without it they read null, which means redacted, not zero. Never put a redacted value into arithmetic.
PATCH /products/{product_id} changes a product's everyday fields. It works the same for every product type, because these fields belong to the product itself rather than to its material or recipe object. It needs catalog:write and returns the product as GET /products/{product_id} does.
| Field | In NutraSoft | Notes |
|---|---|---|
name | Product Name | 1 to 100 characters. Surrounding spaces are trimmed. |
description | Description | Up to 5000 characters. null or an empty string clears it. |
price | Selling Price | Per inventory unit. |
cost | Cost (Gross) | Per inventory unit. |
minimum_on_hand | Min. Stock Level | In inventory units. Stock on hand below it counts as low. 0 means no minimum. |
maximum_on_hand | Max. Stock Level | In inventory units. 0 means no maximum. |
shelf_life | Shelf Life (days) | Days the product is usable after receipt. Lots already on hand keep their expiration dates. |
active | Active | An inactive product stays readable over the API. |
tax1, tax2 | Tax 1, Tax 2 | Whether each of your account's two taxes applies to the product. |
null clears description and is refused with a 400 for every other field: send 0 or false instead.uom_qty * uom_base_conversion base units, while lots count quantity_base in base units, so convert before comparing stock with minimum_on_hand. See Units and quantities.product_code, product_type, operations_cost and the material and recipe fields are refused as unknown fields, with a 400.price, cost, tax1 and tax2 back needs cost:read; without it they are null in the response even though they were saved.cost. On an account that updates costs from bills, the next bill that receives the product sets cost again.source naming your key, so you can ignore your own echo.curl -X PATCH "https://public-api.nutrasoft.ca/v1/products/123" \
-H "Authorization: Bearer $NUTRASOFT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f1c6a52-8d0e-4b7a-9c2f-5e8d1b4a7c90" \
-d '{
"minimum_on_hand": 40,
"price": 24.99
}'{
"data": {
"id": 123,
"active": true,
"allergens": [
{
"allergen_id": 123,
"code": "SAMPLE-01",
"is_major_allergen": false,
"name": "Sample name",
"source": "string"
}
],
"cost": 12.5,
"created_at": "2026-09-10T14:30:00Z",
"description": "string",
"is_deleted": false,
"kind": "material",
"material": {
"banned_country_ids": [
123
],
"food_id": 123,
"substitute_product_ids": [
123
],
"supplier_ids": [
123
]
},
"maximum_on_hand": 12.5,
"minimum_on_hand": 12.5,
"name": "Sample name",
"operations_cost": 12.5,
"price": 12.5,
"product_code": "SAMPLE-01",
"product_type": "RAW_MATERIAL",
"recipe": null,
"shelf_life": 10,
"tag_ids": [
123
],
"tax1": false,
"tax2": false,
"unit_of_measure_id": 123,
"uom_base_conversion": 12.5,
"uom_base_id": 123,
"uom_qty": 12.5,
"updated_at": "2026-09-10T14:30:00Z"
}
}When the product is linked to a Shopify product or a QuickBooks item in NutraSoft, an update is pushed there too, in the same cases as a save in NutraSoft: only to a linked system, and only when a field it mirrors changed.
| Field | Shopify | QuickBooks |
|---|---|---|
name | Title and URL handle | Name |
description | Description | Description |
price | Variant price | Sales price |
cost | Cost per item | Purchase cost |
active | Status, Active or Draft | Active |
cost plus operations_cost.updated_since does not report the deletion. See Lots and incremental sync.