NutraSoft food manufacturing ERP software
Esc
  • GuideGetting startedThree steps to your first call and your first order, then the two shapes every response takes.
  • GuideAuthentication and keysBearer keys, test and live environments, rotation without an outage, and what each scope grants.
  • GuideTest modeA second NutraSoft account of your own, seeded with realistic data, that you can safely break.
  • PageErrorsEvery error code the API returns, what it means, what to do about it and whether to retry.
  • PageConventionsThe rules every operation follows: money, time, paging, sorting, nulls, PATCH and identifiers.
Menu

Products

One resource, one id space, two families of product with different fields, and one update that works for both. The largest comprehension trap in the API.
Updated

In short

  • Every product has kind: material for raw materials, packaging and miscellaneous, recipe for recipes, sub-recipes and finished goods.
  • On a single product exactly one of material and recipe is populated; the other is null.
  • The list leaves allergens, tag_ids, material and recipe null. Fetch one product to read them.
  • PATCH /products/{product_id} updates the name, description, selling price, cost, stock levels, shelf life, active status and tax flags, the same way for every type.
  • 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.

The two families

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.

Familykindproduct_type values
MaterialmaterialRAW_MATERIAL, PACKAGING, MISCELLANEOUS
ReciperecipeRECIPE, 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.

Fields each product family carries
FieldMaterialRecipe
product_typeRAW_MATERIAL, PACKAGING, MISCELLANEOUSRECIPE, SUB_RECIPE, FINISHED_GOOD
kindmaterialrecipe
material.banned_country_idsYesNo, material is null
material.food_idThe nutrition source. Fetch with GET /foods/{food_id}.YesNo, material is null
material.substitute_product_idsYesNo, material is null
material.supplier_idsYesNo, material is null
recipe.byproduct_product_idsNo, recipe is nullYes
recipe.ingredients_enThe generated ingredient declaration.No, recipe is nullYes
recipe.ingredients_frNo, recipe is nullYes
recipe.portion_in_gramsNo, recipe is nullYes
recipe.qc_checkpoint_idsNo, recipe is nullYes
recipe.recipe_grams_yieldNo, recipe is nullYes
Allergensallergens[].allergen_idYesYes
Tagstag_idsYesYes
Units of measureunit_of_measure_idYesYes
Product lotslinked by product_idYesYes

Material fields

  • 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 fields

  • 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

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.

List versus detail

GET /products leaves allergens, tag_ids, material and recipe null: here null means not loaded, not none. Fetch a single product to read them.

Costs

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.

Updating a product

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.

FieldIn NutraSoftNotes
nameProduct Name1 to 100 characters. Surrounding spaces are trimmed.
descriptionDescriptionUp to 5000 characters. null or an empty string clears it.
priceSelling PricePer inventory unit.
costCost (Gross)Per inventory unit.
minimum_on_handMin. Stock LevelIn inventory units. Stock on hand below it counts as low. 0 means no minimum.
maximum_on_handMax. Stock LevelIn inventory units. 0 means no maximum.
shelf_lifeShelf Life (days)Days the product is usable after receipt. Lots already on hand keep their expiration dates.
activeActiveAn inactive product stays readable over the API.
tax1, tax2Tax 1, Tax 2Whether each of your account's two taxes applies to the product.
  • Omitted fields are left unchanged. An explicit null clears description and is refused with a 400 for every other field: send 0 or false instead.
  • Inventory units are the product's packaging. One inventory unit is 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.
  • Some fields cannot be written. Packaging size, units of measure, product_code, product_type, operations_cost and the material and recipe fields are refused as unknown fields, with a 400.
  • Writing money needs no extra scope. Reading price, cost, tax1 and tax2 back needs cost:read; without it they are null in the response even though they were saved.
  • Bills can recalculate cost. On an account that updates costs from bills, the next bill that receives the product sets cost again.
  • Every update is recorded. It appears in the product's history in NutraSoft, credited to your key, and fires product.updated with a source naming your key, so you can ignore your own echo.
PATCH /products/{product_id}
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
}'
Response200
{
  "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"
  }
}

Shopify and QuickBooks

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.

FieldShopifyQuickBooks
nameTitle and URL handleName
descriptionDescriptionDescription
priceVariant priceSales price
costCost per itemPurchase cost
activeStatus, Active or DraftActive
  • Stock levels, shelf life and tax flags are not pushed, so an update that changes only those makes no outside call.
  • For a recipe, sub-recipe or finished good, the QuickBooks purchase cost is cost plus operations_cost.
  • Renaming a product linked to Shopify also changes its Shopify URL handle, as it does from NutraSoft. A cleared description is not cleared in Shopify.
  • QuickBooks is updated only while Push product changes to QuickBooks is on in NutraSoft. The API never creates or links a QuickBooks item.
  • A push that fails does not fail your request: the update is saved and the response is the same. Push results are not reported in the response.
  • HubSpot is not updated by API updates. The next save of the product in NutraSoft updates it.
  • An update to a linked product waits for the pushes, so it can take a few seconds.

What v1 does not do

  • No bill of materials. v1 publishes no component list or recipe lines for a product.
  • No creating or deleting products. Create and delete them in NutraSoft; PATCH /products/{product_id} updates the ones you have.
  • Stock is not a product field. Change quantities on hand through product lots.
  • Deleted products disappear. A deleted product is no longer listed or retrievable, and polling with updated_since does not report the deletion. See Lots and incremental sync.