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.
cost and operations_cost are two separate figures and are never summed for you. Both need cost:read; without it they read null, which means redacted, not zero. Never put a redacted cost into arithmetic.
updated_since does not report the deletion. See Lots and incremental sync.