---
title: "Create Product Component"
method: POST
path: "/v1/product_components"
tags: ["Products"]
---

# Create Product Component

`POST /v1/product_components`

## Request

- Base URL: `https://api.withflex.com`
- URL: `https://api.withflex.com/v1/product_components`
- Auth: HTTP bearer

Registers a product component (an ingredient) and returns it with an `fcomp_`
identifier you can attach to products.

Nutrient values are stored per 100 g of the component. Send `nutrition_basis:
"as_served"` with a `serving_size_g` to have per-serving figures converted for
you. Component names are matched case-insensitively against a shared name table,
so registering a name another account already registered returns that same
`component_id` with your own nutrition attached.

**Registering something you already have is a `409`.** That covers both ways it
happens: describing the same component twice in the same mode, and reusing a
`client_reference_id` on a different component. Both mean the same thing —
this ingredient is already registered, `PATCH` it instead — so a menu sync can
treat `409` as "switch to update" without inspecting the message.

Pass a `client_reference_id` to carry your own identifier for the ingredient.
It is unique within your account and mode, which is what lets you look a
component back up with
`GET /v1/product_components?client_reference_id=…` instead of paging your
catalogue. The same value may be used by another account, and by you in your
other mode.

## Request body

- ProductComponentBodyForCreateProductComponentRequest: An envelope wrapping a single product component object.
  - `product_component` CreateProductComponentRequest, required: Parameters for registering a product component. The name is matched case-insensitively against a shared name table, so registering a name another partner already registered returns that same `component_id` with your own nutrition attached — it does not fail, and it does not expose their figures.
    - `name` string, required: The component's name, e.g. `"kale"`. Trimmed and lowercased before storage.
    - `category` 'base' | 'protein' | 'topping' | 'premium' | 'dressing', required: A component's menu category. An enum on the way in and a plain string on the way out, deliberately. `product_component_nutrition.category` is `text` with no CHECK constraint (per the company convention that the backend owns validation), so a row written by a backfill or by raw SQL can hold a value outside this set. Being strict on write keeps bad values out; being tolerant on read means such a row is still retrievable instead of 500ing on serialization. The variants mirror [`flex_core::product_metadata::VALID_CATEGORIES`], which is what the write path actually validates against; `test_component_category_matches_flex_core` pins the two together so a category added there cannot be silently unreachable through the API. - `base`: The bowl's base — a grain, green or other foundation. - `protein`: A protein. - `topping`: A standard topping. - `premium`: A premium topping. - `dressing`: A dressing. The one category the eligibility engine gates differently.
    - `nutrition_basis` 'per_100g' | 'as_served': The basis the supplied nutrient values are expressed on. Storage is always per 100 g; this only describes the *request*. `as_served` requires a usable `serving_size_g` to convert by. - `per_100g`: The values are already per 100 g. The default. - `as_served`: The values describe one `serving_size_g` portion as served, and are converted to per 100 g before being stored.
    - `serving_size_g` number, double, nullable: The standard portion of this component, in grams. Not a per-100 g nutrient: it is never rescaled, and it is what `as_served` figures are converted by.
    - `nutrition` ComponentNutritionValues: Nutrient values for one component. **Every value is per 100 g of the component**, never per serving — see `nutrition_basis` on the write paths for how per-serving figures are converted on the way in. A `null` means *not supplied*, which is distinct from `0`: the eligibility engine treats a missing nutrient as a reason it cannot produce a split, whereas a zero is a real measurement.
      - `calories` number, double, nullable: Energy, kcal per 100 g.
      - `total_fat_g` number, double, nullable: Total fat, g per 100 g. Informational — the eligibility gate uses saturated fat.
      - `saturated_fat_g` number, double, nullable: Saturated fat, g per 100 g. This is the fat the eligibility gate uses.
      - `trans_fat_g` number, double, nullable: Trans fat, g per 100 g.
      - `carbohydrate_g` number, double, nullable: Carbohydrate, g per 100 g.
      - `fiber_g` number, double, nullable: Fiber, g per 100 g.
      - `protein_g` number, double, nullable: Protein, g per 100 g.
      - `sodium_mg` number, double, nullable: Sodium, mg per 100 g.
      - `added_sugar_g` number, double, nullable: Added sugar, g per 100 g.
    - `vegan` boolean, nullable: Whether the component is vegan. Defaults to `false`.
    - `gluten_free` boolean, nullable: Whether the component is gluten-free. Defaults to `false`.
    - `canonical_food_key` string, nullable: The join key into Flex's national price book. Optional here — the eligibility engine, not this endpoint, is what requires it for a non-dressing component. Stored normalized: lowercased, trimmed, and with hyphens folded into underscores, so `Goat-Cheese` is saved and returned as `goat_cheese`. The price book is snake_case throughout, and this is what makes your spelling and ours the same key.
    - `client_reference_id` string, nullable: Your own identifier for this ingredient — a menu item id, POS SKU, or whatever your catalogue keys on. Unique within your account and mode, so a re-sync that reuses one is a `409` rather than a second component pointing at the same ingredient. That is what makes it usable as a sync key: register once, then look the component up with `GET /v1/product_components?client_reference_id=…` instead of paging your whole catalogue. Another account may use the same value for their own ingredient, and so may you in your other mode.
    - `metadata` object, nullable: Key-value pairs to store on the component. Yours to read back; Flex does not interpret them.

## Response `200`

An envelope wrapping a single product component object.

- ProductComponentBodyForV1ProductComponent: An envelope wrapping a single product component object.
  - `product_component` V1ProductComponent, required: A component together with the nutrition this partner has recorded for it. Every nutrient value is per 100 g.
    - `component_id` string, required: The component's unique identifier. This is the id `component_items` on `PATCH /v1/products/{id}` accepts.
    - `component_nutrition_id` string, required: The identifier of the nutrition record holding your figures for this component.
    - `name` string, required: The component's name, normalized to lowercase.
    - `shared` boolean, required: Whether the underlying name row is shared across partners rather than owned by you. `true` for everything registered through this endpoint today, which is why the name cannot be changed.
    - `test_mode` boolean, required: Whether these figures belong to test mode.
    - `category` string, required: The component's menu category.
    - `serving_size_g` number, double, nullable: The standard portion, in grams. Not a per-100 g nutrient.
    - `nutrition` ComponentNutritionValues, required: Nutrient values for one component. **Every value is per 100 g of the component**, never per serving — see `nutrition_basis` on the write paths for how per-serving figures are converted on the way in. A `null` means *not supplied*, which is distinct from `0`: the eligibility engine treats a missing nutrient as a reason it cannot produce a split, whereas a zero is a real measurement.
      - `calories` number, double, nullable: Energy, kcal per 100 g.
      - `total_fat_g` number, double, nullable: Total fat, g per 100 g. Informational — the eligibility gate uses saturated fat.
      - `saturated_fat_g` number, double, nullable: Saturated fat, g per 100 g. This is the fat the eligibility gate uses.
      - `trans_fat_g` number, double, nullable: Trans fat, g per 100 g.
      - `carbohydrate_g` number, double, nullable: Carbohydrate, g per 100 g.
      - `fiber_g` number, double, nullable: Fiber, g per 100 g.
      - `protein_g` number, double, nullable: Protein, g per 100 g.
      - `sodium_mg` number, double, nullable: Sodium, mg per 100 g.
      - `added_sugar_g` number, double, nullable: Added sugar, g per 100 g.
    - `vegan` boolean, required: Whether the component is vegan.
    - `gluten_free` boolean, required: Whether the component is gluten-free.
    - `canonical_food_key` string, nullable: The join key into Flex's national price book, or `null` if not yet mapped.
    - `client_reference_id` string, nullable: Your own identifier for this ingredient, or `null` if you did not set one.
    - `metadata` object, nullable: The key-value pairs you stored on this component, or `null` if none.
    - `created_at` string, required: A timestamp encoded as an RFC 3339 / ISO 8601 string (e.g. `2026-06-15T14:30:00Z`).
    - `updated_at` string: A timestamp encoded as an RFC 3339 / ISO 8601 string (e.g. `2026-06-15T14:30:00Z`).

## Other responses

- `401`: Unauthorized
- `403`: Forbidden
- `404`: Not Found
- `409`: Conflict
- `422`: Validation Error
- `429`: Too Many Requests

## Changes

- **2026-11-03** `eec642897a22`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/withflex/apis/flex-api/changes/v1/product_components/post.md)

---

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