Ice Cream Calc

REST API Reference

Complete technical reference for all Ice Cream Calculator REST API endpoints: authentication, recipes (list, get, create, update, delete, calculate, balance), ingredients (list, get, create, update, sub-ingredients and allergens), recipe and ingredient collections (list, get, create, rename, delete, add and remove items), allergen and ingredient group lookups, request/response formats, error handling, and rate limits.

Updated

Introduction

This document provides complete technical reference for all Ice Cream Calculator REST API endpoints. Each endpoint is documented with request format, response structure, and example code.

📘 New to the API? Start with the Getting Started Guide to learn how to generate your API key and make your first request.


Base URL

https://www.icecreamcalc.app/api

All endpoints are relative to this base URL. Always include the www: requests sent to any other address are answered with a redirect to this one, and many HTTP clients do not follow a redirect for POST, PUT and DELETE (curl does not follow redirects at all unless you add -L). Browsers report a redirected API call as a CORS error.


Authentication

All endpoints (except /recipes/test) require authentication using an API key.

Include API Key in Header

X-API-Key: icc_your_api_key_here

Authentication Errors

Error HTTP Code Response
Missing API Key 401 {"error": "API key is required", "details": "Include X-API-Key header with your API key"}
Invalid API Key 401 {"error": "Invalid API key", "details": "The provided API key is not valid or the user account is inactive"}

“Invalid API key” is returned for a wrong, revoked or regenerated key, and also when your Premium subscription has expired, your account is not active, or your account has been locked by an administrator. The response does not say which.


Endpoints Overview

Endpoint Method Description
/recipes/test GET Check that the API is reachable (no API key needed)
/recipes GET List all your recipes (compact summaries)
/recipes/{id} GET Get a single recipe with ingredients
/recipes/{id}/calculated GET Get calculated data for a recipe
/recipes POST Create a new recipe
/recipes/{id} PUT Update a recipe (full replacement)
/recipes/{id} DELETE Delete a recipe permanently
/recipes/{id}/balance POST Run Smart Balance and get a proposal (nothing is saved)
/ingredients GET List all accessible ingredients (summaries, or full details with ?detail=full)
/ingredients/{id} GET Get a single ingredient with full details
/ingredients POST Create a personal ingredient
/ingredients/{id} PUT Update one of your personal ingredients (full replacement)
/ingredients/lookups GET Valid values for ingredient fields, and your custom property names
/allergens GET Built-in allergen names and your own labeling allergens
/ingredientgroups GET Your sub-ingredient groups
/collections GET List your recipe and ingredient collections
/collections/{id} GET Get a collection with its recipes or ingredients
/collections POST Create a collection
/collections/{id} PUT Rename a collection
/collections/{id} DELETE Delete a collection (its recipes and ingredients are kept)
/collections/{id}/items/{itemId} PUT Add a recipe or ingredient to a collection
/collections/{id}/items/{itemId} DELETE Remove a recipe or ingredient from a collection

Recipe Endpoints

GET /api/recipes

Retrieve a list of all your recipes with basic information, most recently changed first. Temporary recipes are not included. This endpoint returns compact summaries – use GET /api/recipes/{id} for the full recipe.

Request

GET /api/recipes
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

{
  "success": true,
  "count": 2,
  "recipes": [
    {
      "id": 1,
      "name": "Classic Vanilla Ice Cream",
      "description": "Traditional vanilla base",
      "info": "Traditional vanilla base",
      "externalId": "ERP-REC-0042",
      "totalWeight": 1000.0,
      "createdDate": "2025-01-15T10:30:00Z",
      "ingredientCount": 6,
      "collections": [
        { "id": 7, "name": "Summer menu" }
      ]
    }
  ]
}

Recipe Summary Fields

Field Type Description
id integer Unique recipe identifier
name string Recipe name
description string Recipe description – legacy alias of info, kept for compatibility
info string Recipe description (same value as description; matches the field name on the single-recipe endpoint)
externalId string or null Your external-system ID for this recipe – lets you match recipes from one list call
totalWeight number Mix weight in grams: the sum of the ingredient weights, without inclusions and infusions (the same value as mixWeight on GET /api/recipes/{id})
createdDate string (ISO 8601) When the recipe was created
ingredientCount integer Number of ingredient lines in the recipe, inclusions and infusions included
collections array Your recipe collections that contain this recipe, as { "id": 7, "name": "Summer menu" }, sorted by name; empty when none

Note: The list endpoint does not include ingredients, notes, category, tags, or metadata – those are on GET /api/recipes/{id}.

Example (curl)

curl -H "X-API-Key: icc_your_key" \
     https://www.icecreamcalc.app/api/recipes

Example (Python)

import requests

response = requests.get(
    "https://www.icecreamcalc.app/api/recipes",
    headers={"X-API-Key": "icc_your_key"}
)

recipes = response.json()
for recipe in recipes['recipes']:
    print(f"{recipe['name']} - {recipe['totalWeight']}g")

GET /api/recipes/{id}

Retrieve detailed information for a single recipe, including all ingredients.

Request

GET /api/recipes/1
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

{
  "success": true,
  "recipe": {
    "id": 1,
    "name": "Classic Vanilla Ice Cream",
    "info": "Traditional vanilla base",
    "notes": "Heat base to 85C, cool overnight before churning.",
    "externalId": "ERP-REC-0042",
    "metadata": { "externalId": "repo-123", "version": 3 },
    "category": "IceCream",
    "tags": ["Home", "LowSugar"],
    "bestBeforeDays": 180,
    "overrun": -1,
    "evaporation": 0.05,
    "createdDate": "2025-01-15T10:30:00Z",
    "changedDate": "2025-11-08T14:20:00Z",
    "hasChart": true,
    "chartName": "Ice Cream Base",
    "mixWeight": 1000.0,
    "finalWeight": 950.0,
    "ingredients": [
      {
        "id": 1,
        "ingredientId": 42,
        "ingredientName": "Heavy Cream",
        "ingredientSource": "Default",
        "weight": 500.0,
        "inclusion": false,
        "infusion": false,
        "excludeFromEvaporation": false,
        "position": 0,
        "amountMin": 0.0,
        "amountMax": 1.0
      }
    ],
    "collections": [
      { "id": 7, "name": "Summer menu" }
    ]
  }
}

Recipe Object Fields

Field Type Description
id integer Unique recipe identifier
name string Recipe name
info string Recipe description
notes string Free-text notes (up to 2000 characters)
externalId string or null Your external-system ID (up to 50 characters); null when unset
metadata object or null Your own JSON object, stored and returned verbatim (see “The metadata field” below); null when unset
category string or null Recipe category (see accepted values below); null when uncategorized
tags array of strings Recipe tags (see accepted values below); empty when none
bestBeforeDays integer or null Shelf life in days; null when unspecified
overrun number Per-recipe overrun percentage. -1 means “inherit the account’s global overrun default”
evaporation number Evaporation as a fraction of the weight of the cooked mix ingredients (0.05 = 5%)
hasChart / chartName boolean / string Whether a chart is linked, and its name
mixWeight number Mix weight in grams (before freezing; inclusions and infusions are not counted)
finalWeight number Final weight in grams (after evaporation)
ingredients array List of ingredients with weights, positions, and amount bounds. ingredientName is the stored name (the English name for library ingredients); ingredientSource is "Default" (shared library) or "Personal" (your own ingredient)
collections array Your recipe collections that contain this recipe, as { "id": 7, "name": "Summer menu" }, sorted by name. To change them, send collectionIds with POST or PUT, or use the collection endpoints

Error Responses

HTTP Code Response Meaning
404 {"error": "Recipe not found"} Recipe with this ID doesn’t exist or isn’t available to you (temporary recipes also return 404)
403 {"error": "Access denied", "details": "You don't have permission to access this recipe"} Recipe belongs to another user

Example (curl)

curl -H "X-API-Key: icc_your_key" \
     https://www.icecreamcalc.app/api/recipes/1

GET /api/recipes/{id}/calculated

Retrieve calculated data for a recipe including PAC, POD, serving temperatures, and all other ice cream science properties.

Note: This endpoint performs full recipe calculations, so it may take slightly longer than other endpoints.

Request

GET /api/recipes/1/calculated
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

{
  "success": true,
  "data": {
    "recipeId": 1,
    "recipeName": "Classic Vanilla Ice Cream",
    "values": {
      "PAC se": {
        "name": "PAC se",
        "displayValue": "267.40",
        "numericValue": 267.4,
        "info": "Potere Anti-Congelante, freezing point depression power of sugars and polyols",
        "isTemperature": false
      },
      "POD": {
        "name": "POD",
        "displayValue": "180.60",
        "numericValue": 180.6,
        "info": "Relative sweetness",
        "isTemperature": false
      },
      "Serving temp": {
        "name": "Serving temp",
        "displayValue": "-11.8°C, 10.8°F",
        "numericValue": -11.8,
        "info": "Estimated serving temp for hard scoop ice cream",
        "isTemperature": true
      }
      // ... many more calculated values
    }
  }
}

The keys of values are the names the app uses for each value (case-sensitive, spaces included). Each value has:

  • displayValue – the formatted text the app shows, with units (temperatures in both °C and °F)
  • numericValue – the number, or null when the value is undefined for this recipe, for example Fat/Protein in a recipe without protein. For temperatures it is in °C, or in °F if your account shows temperatures in Fahrenheit
  • info – a short description; isTemperature – true for temperatures

Calculated Values Included

Group Example keys
Freezing & PAC PAC se, PAC normalized, PACtot (including salt), PACtot normalized, Freezing point, HF, FW08 … FW16 (frozen water at −8 to −16 °C)
Temperature & texture Serving temp, Serving temp gelato, Extraction temp, Coldness, Coldness Gelato, Creaminess, Body, Crumbly, % Serving temp, % Gelato serving temp, % Extraction FW
Sweetness and sugars POD, Perceived sweetness, Total sugars, Sugar, Added sugars, Lactose, Lactose concentration, Sucrose, Glucose, Fructose, Maltose, Galactose, Trehalose, Other sugars, Unassigned sugars, Maltotriose, Higher order sugars, Sugar composition coverage
Composition (mostly percent of weight) Total fat, Milk fat, Saturated fat, Protein, MSNF, Milk solids, Total solids, Carbohydrates, Fiber, Polyols, Stabilizers, Emulsifiers, Salt, Alcohol, Water, Fat/Protein … Most composition values also come as …/Water and …/TS (for example Total fat/Water, Total fat/TS)
Weight & volume Weight, Final weight, Final water, Mix volume, Ice cream volume, Density, Overrun, Evaporation, Evaporation %, AddIns_Weight, AddIns_Volume
Cost Total Cost, Cost per Kg, Cost per Liter
Nutrition labels Energy/100g, EnergyKj/100g, and the label fields lblEU_*, lblUS_* and lblAU_*
Ingredients I_<ingredient name> (grams) and I_<ingredient name>_P (percent); A_… for inclusions and infusions
Custom properties Listed under the names you gave your custom properties, when the recipe has a value for them
  • Serving temperatures follow your own frozen-water settings (Settings → Temperature Settings: Ice Cream (FW%), Gelato (FW%), Extraction (FW%)), so they match what you see in the app. See Understanding Scoopability and Serving Temperatures.
  • Sugar items: Sucrose to Other sugars come from ingredients with a recorded sugar composition; Unassigned sugars is the non-lactose sugar of the other ingredients, so these seven plus Lactose and Unassigned sugars add up to Total sugars. Sugar composition coverage shows how much of the total sugars is accounted for. Perceived sweetness is on the POD scale, for comparison with POD. See Sugar Composition.
  • Not included: results of your own formulas (user formulas) are not part of the API response.

Example (Python)

import requests

response = requests.get(
    "https://www.icecreamcalc.app/api/recipes/1/calculated",
    headers={"X-API-Key": "icc_your_key"}
)

values = response.json()['data']['values']
pac = values['PAC se']['numericValue']
pod = values['POD']['numericValue']
serving = values['Serving temp']['displayValue']

print(f"PAC: {pac}, POD: {pod}, serving temp: {serving}")

POST /api/recipes

Create a new recipe with ingredients.

Request

POST /api/recipes
Headers:
  X-API-Key: icc_your_api_key_here
  Content-Type: application/json

{
  "name": "Mango Sorbet",
  "info": "Fruity sorbet base",
  "notes": "Blend fruit before adding syrup.",
  "externalId": "ERP-REC-0043",
  "category": "Sorbet",
  "tags": ["Vegan", "DairyFree"],
  "bestBeforeDays": 180,
  "overrun": -1,
  "metadata": { "externalId": "repo-123", "version": 1 },
  "evaporation": 0,
  "chartName": "Sorbet Base",
  "collectionIds": [7],
  "ingredients": [
    {
      "ingredientId": 42,
      "weight": 500,
      "inclusion": false,
      "infusion": false,
      "amountMin": 0,
      "amountMax": 1
    }
  ]
}

Request Fields

Field Type Required Constraints and omitted behaviour
name string Yes Must be non-empty; max 200 characters (longer → 400)
info string No Description; empty when omitted
notes string No Max 2000 characters (longer → 400); empty when omitted
externalId string No Max 50 characters (longer → 400); empty when omitted
category string No Must match an accepted category value, case-insensitively (unknown → 400); uncategorized when omitted
tags array of strings No Each tag must match an accepted tag value, case-insensitively (unknown → 400); duplicates are collapsed; no tags when omitted
bestBeforeDays integer No Must not be negative (→ 400); unspecified when omitted
overrun number No -1 (inherit the global default) or any value ≥ 0; other negatives → 400. Omitted = -1
metadata object No Top level must be a JSON object; compact serialization max 4096 characters (violations → 400); unset when omitted. See “The metadata field” below
evaporation number No A fraction, not a percentage: 0.05 = 5% of the weight of the cooked mix ingredients. Default 0
chartName string No Looked up by name, case-insensitively. If not found, the recipe is still created without a chart
collectionIds array of integers No IDs of your recipe collections to put the recipe in (from GET /api/collections). An ID that is not one of your recipe collections → 400, and nothing is saved. In no collection when omitted
ingredients array Yes At least one. Each: ingredientId (required – must exist and be accessible to you), weight (grams), inclusion, infusion, excludeFromEvaporation, amountMin, amountMax (all optional: the flags default to false, amountMin to 0 and amountMax to 1, which together mean no range; null counts as omitted)

Accepted category values

IceCream, Gelato, Sorbet, Sherbet, FrozenYogurt, Granita, SoftServe, Special, Confectionery, Other, Ingredient, Topping

Accepted tags values

Vegan, EggFree, DairyFree, LowSugar, Home, BatchFreezer, NinjaCreami, PacoJet, Alcohol, Ganache, Commercial

💡 Tip: Matching is case-insensitive on write ("vegan" is accepted), but responses always return the canonical names listed above – so what you read back can be sent again unchanged.

Response (201 Created)

{
  "success": true,
  "recipeId": 15,
  "message": "Recipe created successfully",
  "recipe": { /* the full recipe object, as returned by GET /api/recipes/{id} */ },
  "warnings": null
}

warnings is a list of messages when a follow-up step failed after the recipe itself was saved (setting its collections); otherwise null. Check it, and set the collections again with a PUT or the collection endpoints.

Error Responses

HTTP Code Example Meaning
400 {"error": "Validation failed", "details": "Recipe name is required"} Missing name, no ingredients, bad field value (unknown category/tag, negative bestBeforeDays, invalid overrun, metadata not an object or over the cap)
400 {"error": "Invalid ingredient", "details": "Ingredient with ID 999 does not exist or you don't have access to it"} An ingredient ID doesn’t exist or isn’t accessible to your account
400 {"error": "Invalid collection", "details": "CollectionIds 99 are not your recipe collections. ..."} An ID in collectionIds is not one of your recipe collections (it doesn’t exist, or it is an ingredient collection)
403 {"error": "Recipe limit reached", "details": "..."} Your plan’s recipe limit is reached

The metadata field

metadata is an opaque JSON key-value store for your own use – for example syncing your system’s IDs, versions, or timestamps. Ice Cream Calculator stores and returns it but never reads, interprets, or indexes its contents.

  • The top level must be a JSON object ({...}). Arrays, strings, numbers, and other bare values are rejected with 400. Nested values may be anything.
  • The compact (no-whitespace) serialization must be at most 4096 characters; larger → 400.
  • Returned as a nested JSON object on GET /api/recipes/{id}, the create response, and the update response – never as a quoted string. Not included in the list endpoint.
  • Full replacement on update: omitting metadata on a PUT clears it.

Example (curl)

curl -X POST \
     -H "X-API-Key: icc_your_key" \
     -H "Content-Type: application/json" \
     -d '{"name":"Mango Sorbet","category":"Sorbet","tags":["Vegan"],"ingredients":[{"ingredientId":42,"weight":500}]}' \
     https://www.icecreamcalc.app/api/recipes

PUT /api/recipes/{id}

Update an existing recipe. This is a full replacement: the ingredient list is completely replaced, and every optional field except collectionIds resets to its default when omitted – the API does not merge with the previous values.

Request

Same body format and validation as POST /api/recipes (see the request-field table above). Key differences:

Field When omitted on PUT
info, notes, externalId Cleared (set to empty)
category, tags, bestBeforeDays, metadata Cleared (unset / no tags / null)
overrun Reset to -1 (inherit the global default)
evaporation Reset to 0
chartName Chart link cleared (also cleared if the name isn’t found)
collectionIds Kept: the recipe stays in its current collections. A list replaces them; [] removes the recipe from all of them
ingredients Not allowed to omit – at least one is required; the previous list is always fully replaced

⚠️ Warning: Because updates are full replacements, always send every field you want to keep. A safe pattern is: GET /api/recipes/{id}, modify the fields you need, then PUT the whole thing back.

Note: a PUT keeps locks and class chips. The request has no fields for the settings you make in the app, so each ingredient line in your request takes them from the recipe’s previous line with the same ingredient: the lock, the Smart Balance class chip (Flavor, Stabilizer, and so on), the waste (scale) factor and the displacement override. When an ingredient appears more than once, the lines are paired in recipe order. An ingredient that is new to the recipe starts unlocked, without a chip, with its default waste factor. A balance-then-PUT round trip therefore keeps the settings the next balance run needs.

Response (200 OK)

{
  "success": true,
  "recipe": { /* the full updated recipe object */ },
  "warnings": null
}

warnings works as on POST.

Error Responses

Same validation errors as create (400), plus:

HTTP Code Example Meaning
404 {"error": "Recipe not found", "details": "..."} The recipe doesn’t exist
403 {"error": "Access denied", "details": "You don't have permission to update this recipe"} The recipe belongs to another user
403 {"error": "Recipe edit restricted", "details": "..."} Your plan does not allow editing this recipe (the Free plan’s recipe limit). Because the API requires Premium, you should rarely see this
400 {"error": "Cannot update temporary recipe", "details": "..."} Temporary recipes cannot be updated
400 {"error": "Invalid ingredient", "details": "Recipes inside recipes can go 3 levels deep, and this would make 'Chocolate Base' 4 levels deep: Chocolate Base → Praline → Hazelnut Paste → Roasted Hazelnuts."} The update would nest recipe-derived ingredients more than three levels deep, or in a loop (“'Chocolate Base' would contain itself: …”). The message names the recipes involved. See Recipe-Derived Ingredients

DELETE /api/recipes/{id}

Permanently delete a recipe and all its ingredients. This cannot be undone.

Request

DELETE /api/recipes/1
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

{
  "success": true,
  "message": "Recipe deleted successfully",
  "recipeId": 1
}

Error Responses

HTTP Code Meaning
404 Recipe doesn’t exist
403 Recipe belongs to another user
400 Temporary recipes cannot be deleted via the API

Example (curl)

curl -X DELETE \
     -H "X-API-Key: icc_your_key" \
     https://www.icecreamcalc.app/api/recipes/1

POST /api/recipes/{id}/balance

Run Smart Balance on a recipe against its linked chart and get back a full diagnostic proposal.

💡 Non-mutating: This endpoint never saves anything. It balances a copy of the stored recipe and returns the proposed weights with diagnostics. If you want to keep the proposal, send a PUT /api/recipes/{id} with the new ingredient weights – but read the warning under PUT first.

Request

No request body. The balance run is reconstructed entirely from stored recipe state:

  • Targets come from the recipe’s linked chart (the chart items marked for balancing).
  • Locked ingredients (set in the recipe editor) keep their weights.
  • Classification chips saved on the recipe’s ingredients (Flavor, Stabilizer, Emulsifier, etc.) drive the per-ingredient bounds; ingredients without a saved chip use their own min/max amount settings.
POST /api/recipes/1/balance
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

{
  "success": true,
  "result": {
    "status": "Converged",
    "message": null,
    "mixWeightBefore": 1000.0,
    "mixWeightAfter": 1000.0,
    "wasReverted": false,
    "bestIteration": 4,
    "initialOutOfRangeCount": 3,
    "proposedOutOfRangeCount": 0,
    "elapsedMs": 412,
    "iterationCount": 4,
    "targets": [
      {
        "propertyKey": "Total fat",
        "target": 10.0,
        "initialValue": 8.2,
        "finalValue": 10.0,
        "isLinear": true,
        "priority": 1.0,
        "achievableMin": 6.5,
        "achievableMax": 14.8,
        "isUnmovable": false,
        "unmovableReason": null,
        "rangeAchievable": true,
        "bindingIngredients": []
      }
    ],
    "iterations": [
      { "iteration": 1, "objective": 2.31, "maxWeightChange": 42.5, "elapsedMs": 118 }
    ],
    "ingredients": [
      {
        "ingredientId": 42,
        "name": "Heavy Cream",
        "initialWeight": 250.0,
        "finalWeight": 291.3,
        "class": "Unknown",
        "lowerBoundGrams": 0.0,
        "upperBoundGrams": 1000.0,
        "locked": false,
        "excluded": false
      }
    ],
    "classification": {
      "success": true,
      "errorMessage": null,
      "classifications": [
        { "name": "Vanilla Extract", "class": "Flavor", "recMinPct": null, "recMaxPct": null, "reason": null }
      ]
    }
  }
}

Result Fields

Field Type Description
status string One of Converged, MaxIterationsReached, Infeasible, SolverUnavailable, Failed
message string or null Human-readable explanation (e.g. why the run stopped early or reverted)
mixWeightBefore / mixWeightAfter number Total mix weight before and after (mass is held fixed, so these should match)
wasReverted boolean True when the safety net kept the original weights because no proposal beat the input recipe
bestIteration integer Which state the returned weights come from: 0 = the input recipe, N = after N accepted solver steps
initialOutOfRangeCount / proposedOutOfRangeCount integer How many targets were out of range before, and in the proposal
elapsedMs integer Total run time in milliseconds
iterationCount integer Number of solver iterations (the iterations array has one entry per iteration)

Per-Target Fields (targets)

Field Type Description
propertyKey string The chart property (e.g. “Total fat”)
target / initialValue / finalValue number What was asked for, where the property started, and where the proposal lands
isLinear boolean Whether the property responds linearly to weight changes
priority number Weighting of this target in the optimization
achievableMin / achievableMax number or null The locally achievable interval under the ingredient bounds; null when not computed. Treat “out of reach” as a strong warning rather than a proof
isUnmovable / unmovableReason boolean / string or null True when no redistribution of the adjustable ingredients can move this property at all, with the reason
rangeAchievable boolean or null false when the requested range lies outside the achievable interval; null when not computed
bindingIngredients array of strings When the range is unachievable: the ingredient bounds standing in the way (up to three, ranked by influence)

Per-Ingredient Fields (ingredients)

Field Type Description
ingredientId / name integer / string Which ingredient
initialWeight / finalWeight number Weight in grams before, and in the proposal – use these for the follow-up PUT
class string The functional class used for bound assignment (Flavor, Stabilizer, Emulsifier, Fiber, HighIntensitySweetener, Structural, Other, or Unknown when unclassified)
lowerBoundGrams / upperBoundGrams number The weight bounds the solver used for this ingredient
locked boolean Weight was held fixed
excluded boolean Excluded from balancing (inclusions and infusions)

The iterations array traces the solver (iteration, objective, maxWeightChange, elapsedMs per step), and classification echoes the classification set the run used (success, errorMessage, and per-ingredient name/class/recMinPct/recMaxPct/reason entries).

Error Responses

HTTP Code Example Meaning
400 {"error": "No chart linked", "details": "This recipe has no linked chart. Link a chart (its ranges define the balance targets) and try again."} The recipe has no linked chart (or the linked chart couldn’t be loaded)
404 / 403   As for GET /api/recipes/{id}
429 {"success": false, "error": "Rate limit exceeded", "details": "..."} The balance endpoint’s tighter rate limit was exceeded (see Rate Limiting)

Note: If the chart has no properties marked for balancing, or every ingredient is locked or an add-in, the endpoint still returns 200 – with status: "Converged", a message explaining the early exit (e.g. “Chart has no balancing targets”), unchanged weights, and empty targets/iterations/ingredients arrays. Check the message field, not just the status.

Typical agent workflow

  1. POST /api/recipes/1/balance – get the proposal
  2. Inspect status, wasReverted, the per-target achievability, and the per-ingredient finalWeight values
  3. If you like the proposal: GET /api/recipes/1, set each ingredient’s weight to the proposal’s finalWeight, and PUT the recipe back
  4. The PUT keeps the recipe’s locks and class chips (see the note under PUT), so you can balance the recipe again straight away

Example (curl)

curl -X POST \
     -H "X-API-Key: icc_your_key" \
     https://www.icecreamcalc.app/api/recipes/1/balance

Ingredient Endpoints

You can read every ingredient you have access to, and create and update your own personal ingredients, including the sub-ingredients and allergens used on your product labels. Shared library ingredients and recipe-derived ingredients cannot be changed through the API, and ingredients cannot be deleted. Library ingredients are returned with their English names; translated names that the app may show you are not used by the API.

GET /api/ingredients

Retrieve a list of all ingredients you have access to (personal ingredients + shared/default ingredients), sorted by category and then by name. Ingredients you have hidden in the app are included.

Note: By default this endpoint returns basic information only. Add ?detail=full to get every ingredient with all the fields of GET /api/ingredients/{id} in one response – much faster than one request per ingredient, and kinder to your rate limit.

Request

GET /api/ingredients
GET /api/ingredients?detail=full
Headers:
  X-API-Key: icc_your_api_key_here

Query Parameters

Parameter Values Description
detail summary (default) or full With full, each entry in ingredients is a complete ingredient object instead of a summary. Any other value returns 400

Response (200 OK)

{
  "success": true,
  "count": 153,
  "ingredients": [
    {
      "id": 1,
      "name": "Whole Milk",
      "category": "Dairy",
      "source": "Default",
      "isRecipeDerived": false,
      "collections": [],
      "created": "2024-01-01T00:00:00Z",
      "changed": "2024-01-01T00:00:00Z"
    },
    {
      "id": 42,
      "name": "My Custom Sugar",
      "category": "Sugar",
      "source": "Personal",
      "isRecipeDerived": false,
      "collections": [
        { "id": 3, "name": "Gelato bases" }
      ],
      "created": "2025-11-01T10:30:00Z",
      "changed": "2025-11-08T14:20:00Z"
    }
  ]
}

Ingredient Summary Fields

Field Type Description
id integer Unique ingredient identifier
name string Ingredient name
category string Category (Dairy, Sugar, Fat, etc.)
source string “Default” (shared library ingredient) or “Personal” (your own ingredient)
isRecipeDerived boolean True if created from a recipe
collections array Your ingredient collections that contain this ingredient, as { "id": 3, "name": "Gelato bases" }; empty when none
created / changed string (ISO 8601) When the ingredient was created and last changed

Example (curl)

curl -H "X-API-Key: icc_your_key" \
     https://www.icecreamcalc.app/api/ingredients

# Everything, with full details
curl -H "X-API-Key: icc_your_key" \
     "https://www.icecreamcalc.app/api/ingredients?detail=full"

GET /api/ingredients/{id}

Retrieve complete details for a single ingredient: all nutritional data and ice cream properties, its sub-ingredients and your allergens for labels, your named custom properties, and your own settings for it (cost, physical state, stock and collections).

Request

GET /api/ingredients/1
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

Values are illustrative.

{
  "success": true,
  "ingredient": {
    "id": 1,
    "name": "Whole Milk",
    "category": "Dairy",
    "info": "Fresh whole milk",
    "source": "Default",
    "sourceRecipeId": null,
    "isRecipeDerived": false,
    "lastUpdatedFromRecipe": null,

    // Composition: decimal fractions of the weight (0.035 = 3.5 g per 100 g)
    "water": 0.875,
    "totalFat": 0.035,
    "saturatedFat": 0.021,
    "protein": 0.033,
    "carbohydrates": 0.048,
    "totalSugars": 0.048,
    "lactose": 0.048,

    // Sugar composition: null = not recorded
    "sucrose": null,
    "glucose": null,
    "fructose": null,
    "maltose": null,
    "galactose": null,
    "trehalose": null,
    "otherSugars": null,
    "maltotriose": null,
    "higherOrderSugars": null,

    // Ice cream specific
    "msnf": 0.089,
    "milkSolids": 0.124,
    "milkProtein": 0.033,
    "milkFat": 0.035,
    "totalSolids": 0.125,
    "pac": 0.048,
    "pod": 0.008,

    // Recipe defaults and your own data
    "displacement": null,
    "defaultScale": 1.0,
    "costKg": 1.2,
    "custom1": 0,
    // ... custom2 to custom10

    // Your named custom properties (unnamed slots are left out)
    "customProperties": [
      { "key": "Custom1", "name": "Brix", "value": 0 }
    ],

    // Built-in allergens
    "contains": ["Milk"],
    "mayContain": [],

    // Labels: sub-ingredients and your own allergens
    "subIngredients": [],
    "userAllergens": [],

    // Your own settings for this ingredient
    "physicalState": "Auto",
    "isHidden": false,
    "stock": null,
    "collections": [],

    "isShared": true,
    "foodReferenceItemId": null,
    "energyKcal": 63.9,
    "energyKJ": 267.4,

    "created": "2024-01-01T00:00:00Z",
    "changed": "2024-01-01T00:00:00Z"
  }
}

Complete Ingredient Fields

Units: all composition values are decimal fractions of the ingredient’s weight, not grams per 100 g. 0.035 means 3.5 g per 100 g; small nutrients use the same scale (sodium: 0.0004 is 40 mg per 100 g). Multiply by 100 for grams per 100 g.

Basic Information:

  • id, name, category, info, created, changed
  • source – “Default” (shared library) or “Personal” (yours)
  • isRecipeDerived, sourceRecipeId, lastUpdatedFromRecipe – for ingredients made from one of your recipes: the recipe’s ID and when the ingredient was last updated from it (null otherwise)
  • isShared – true for shared library ingredients, which you cannot change through the API
  • foodReferenceItemId – the linked food reference database entry, or null

Nutritional Data:

  • water, totalFat, saturatedFat, transFat, cholesterol
  • sodium, salt, carbohydrates, fiber, totalSugars, addedSugars
  • protein, alcohol, lactose, polyols
  • vitaminD, calcium, iron, potassium
  • energyKcal, energyKJ – energy per 100 g, calculated from the composition (read-only)

Sugar Composition:

  • sucrose, glucose, fructose, maltose, galactose, trehalose, otherSugars – together with lactose, these are part of totalSugars
  • maltotriose, higherOrderSugars – outside totalSugars
  • null means the value is not recorded for this ingredient; 0 means it is known to be absent. See Sugar Composition

Ice Cream Properties:

  • msnf – Milk solids non-fat
  • milkSolids – Total milk solids (msnf + milkFat)
  • milkProtein – Milk protein content
  • milkFat, cocoaFat, cocoaSolids
  • totalSolids, otherSolids
  • pac – Freezing point depression (pure sucrose = 1.0)
  • pod – Relative sweetness (pure sucrose = 1.0)
  • hf – Hardening factor
  • stabilizers, emulsifiers
  • amountMin, amountMax – the ingredient’s stored minimum and maximum amount, as fractions (0–1)

Recipe Defaults and Your Data:

  • displacement – mL displaced per 100 g, used for inclusion volumes; null when not set (the app then estimates it)
  • defaultScale – waste factor applied when the ingredient is added to a recipe (1.0 = no waste, 1.1 = 10% extra)
  • costKg – your cost per kg from your own stock records; 0 when you have not set one
  • custom1 … custom10 – your custom property values, on the same scale as the composition values
  • customProperties – the same values for the slots you have named, as { "key": "Custom1", "name": "Brix", "value": 0.12 }. Unnamed slots are left out; GET /api/ingredients/lookups lists all ten slots
  • physicalState – "Auto", "Liquid" or "Powder": your override for grouping the ingredient as a liquid or a powder on the Production page and recipe cards. "Auto" lets the app decide from the water content
  • isHidden – true when you have hidden the ingredient from your ingredient lists in the app
  • stock – your stock record, or null when you have none: stockWeight (grams, null when not tracked), lowStockThreshold (grams), lastRestocked, expiryDate, storageType (Ambient, Chilled or Frozen) and notes. Read-only
  • collections – your ingredient collections that contain this ingredient, as { "id": 3, "name": "Gelato bases" }. To change them, send collectionIds with POST or PUT, or use the collection endpoints

Allergen Information:

  • contains – Array of allergens this ingredient contains
  • mayContain – Array of potential cross-contamination allergens
  • Both use the built-in allergen names listed by GET /api/allergens

Labels (Sub-ingredients and Your Allergens):

  • subIngredients – the ingredient’s own ingredient list for product labels, in label order. Each has id, name, percentage (0–100, share of the ingredient), isHighlighted (printed in capitals, typically allergens), showPercentage (the percentage is printed after the name), groupId and groupName (one of your sub-ingredient groups, or null), and sortOrder
  • userAllergens – the allergens from your own allergen list assigned to this ingredient. Each has userAllergenId, name, isOther, contains and tracesOf. A normal allergen has exactly one of contains and tracesOf set; for an “Other” warning (isOther: true) both are false and the assignment itself means the warning applies. Only your own allergens are returned

Example (JavaScript)

fetch("https://www.icecreamcalc.app/api/ingredients/1", {
  headers: {"X-API-Key": "icc_your_key"}
})
  .then(r => r.json())
  .then(data => {
    const ing = data.ingredient;
    // Values are fractions of weight: multiply by 100 for g per 100 g
    console.log(`${ing.name}: ${(ing.totalFat * 100).toFixed(1)} g fat, ${(ing.protein * 100).toFixed(1)} g protein per 100 g`);
    console.log(`PAC: ${ing.pac}, POD: ${ing.pod}`);
    if (ing.sucrose === null) {
      console.log("Sugar composition not recorded");
    }
  });

POST /api/ingredients

Create a new personal ingredient. You can include its sub-ingredients, your allergens, your cost per kg and its physical state in the same request.

Request

POST /api/ingredients
Headers:
  X-API-Key: icc_your_api_key_here
  Content-Type: application/json

{
  "name": "Hazelnut Paste 100%",
  "category": "Nut",
  "info": "Roasted Piedmont hazelnuts",
  "water": 0.01,
  "totalFat": 0.61,
  "saturatedFat": 0.045,
  "carbohydrates": 0.17,
  "fiber": 0.097,
  "totalSugars": 0.043,
  "sucrose": 0.043,
  "protein": 0.15,
  "totalSolids": 0.99,
  "amountMin": 0,
  "amountMax": 0.15,
  "defaultScale": 1.0,
  "contains": ["TreeNuts"],
  "mayContain": ["Peanuts"],
  "costKg": 24.5,
  "physicalState": "Auto",
  "subIngredients": [
    { "name": "hazelnuts", "percentage": 100, "isHighlighted": true, "showPercentage": true, "groupId": null }
  ],
  "userAllergens": [
    { "userAllergenId": 12, "contains": true, "tracesOf": false }
  ],
  "collectionIds": [3]
}

Request Fields

The request uses the same field names as the ingredient object, so you can take a GET response, change it, and send it back. Fields that are read-only (such as id, source, energyKcal, milkSolids, stock, collections, customProperties, groupName in subIngredients, and name and isOther in userAllergens) are ignored. To set the collections, use collectionIds.

Field Rules
name Required, at most 128 characters
category Required; one of the categories from GET /api/ingredients/lookups
info Optional, at most 2000 characters
Composition fields (water … emulsifiers), amountMin, amountMax Decimal fractions between 0 and 1 (0.035 = 3.5 g per 100 g). amountMin must not be above amountMax. Omitted values are 0, except amountMax (1)
Sugar composition (sucrose … higherOrderSugars) Fractions between 0 and 1, or null for “not recorded” (the default)
pac, pod, hf Not negative; may be above 1
displacement Above 0, or null to let the app estimate it
defaultScale At least 1.0 (default 1.0)
custom1 … custom10 Any number
contains, mayContain Built-in allergen names from GET /api/allergens. Omitted or empty means none
costKg Your cost per kg, 0 or more, rounded to 2 decimals. null or omitted keeps the current cost
physicalState "Auto", "Liquid" or "Powder". null or omitted keeps the current setting
subIngredients The label ingredient list, in label order: name (required, at most 1000 characters), percentage (0–100), isHighlighted, showPercentage, groupId (one of yours from GET /api/ingredientgroups, or null). At most 200. null or omitted keeps the current list; [] removes them all
userAllergens Allergens from your own list: userAllergenId (from GET /api/allergens, each at most once), contains, tracesOf. For a normal allergen exactly one of the two must be true; for an “Other” warning both are ignored. null or omitted keeps the current assignments; [] removes them all
collectionIds IDs of your ingredient collections the ingredient belongs to (from GET /api/collections). An ID that is not one of your ingredient collections returns 400 “Invalid collection”, and nothing is saved. null or omitted keeps the current collections; [] removes the ingredient from all of them

Response (201 Created)

The created ingredient, exactly as GET /api/ingredients/{id} returns it, with a Location header pointing to it:

{
  "success": true,
  "ingredient": { "id": 4711, "name": "Hazelnut Paste 100%", ... },
  "warnings": null
}

warnings is a list of messages when a follow-up step failed after the ingredient itself was saved (for example writing the cost or the collections); otherwise null. Check it, and correct the reported value with a PUT.

Error Responses

400 Bad Request – a field breaks one of the rules above. The first problem is named in details:

{
  "success": false,
  "error": "Validation failed",
  "details": "totalFat must be a fraction between 0 and 1 (0.035 = 3.5 g per 100 g)"
}

Example (curl)

curl -X POST \
     -H "X-API-Key: icc_your_key" \
     -H "Content-Type: application/json" \
     -d '{"name": "Hazelnut Paste 100%", "category": "Nut", "totalFat": 0.61, "protein": 0.15, "water": 0.01, "totalSolids": 0.99}' \
     https://www.icecreamcalc.app/api/ingredients

PUT /api/ingredients/{id}

Update one of your personal ingredients. The request body is the same as for POST /api/ingredients.

Full replacement: every field you leave out is reset to its default (0, empty, or null for the sugar composition). The exceptions are costKg, physicalState, subIngredients, userAllergens and collectionIds: leaving those out (or sending null) keeps what is there. Always GET the ingredient first, change what you need, and send the whole object back.

Request

PUT /api/ingredients/4711
Headers:
  X-API-Key: icc_your_api_key_here
  Content-Type: application/json

{ ...the ingredient, as returned by GET /api/ingredients/4711, with your changes... }

Response (200 OK)

The updated ingredient as GET /api/ingredients/{id} returns it, plus warnings (see POST).

When you change the composition, the app also updates any of your recipe-derived ingredients made from recipes that use this ingredient, just as it does when you save in the ingredient editor. A changed defaultScale applies when the ingredient is next added to a recipe; unlike the editor, the API does not offer to update recipes that already use it.

Error Responses

Code Error When
400 Validation failed A field breaks one of the rules for POST
403 Shared ingredient The ingredient is a shared library ingredient. Create your own copy with POST /api/ingredients instead. To put a shared ingredient in one of your collections, use PUT /api/collections/{id}/items/{itemId}
404 Ingredient not found The ingredient does not exist or belongs to another user
409 Recipe-derived ingredient The ingredient is made from one of your recipes, so its values come from that recipe. Change the recipe instead

Example (Python)

import requests

BASE = "https://www.icecreamcalc.app/api"
headers = {"X-API-Key": "icc_your_key"}

# Read, change, write back
ing = requests.get(f"{BASE}/ingredients/4711", headers=headers).json()["ingredient"]
ing["totalFat"] = 0.62
ing["costKg"] = 26.0

r = requests.put(f"{BASE}/ingredients/4711", headers=headers, json=ing)
r.raise_for_status()
if r.json().get("warnings"):
    print("Saved with warnings:", r.json()["warnings"])

GET /api/ingredients/lookups

The valid values for ingredient fields, and your names for the ten custom property slots. Use it to build forms or to check values before you send them.

Response (200 OK)

{
  "success": true,
  "categories": ["Alcohol", "Chocolate", "Dairy", "Sugar", ...],
  "physicalStates": ["Auto", "Liquid", "Powder"],
  "builtInAllergens": ["Milk", "Eggs", "TreeNuts", "Peanuts", ...],
  "customProperties": [
    { "key": "Custom1", "name": "Brix" },
    { "key": "Custom2", "name": null },
    // ... up to Custom10
  ]
}

A custom property slot you have not named has name: null.


GET /api/allergens

The built-in allergen names, used in an ingredient’s contains and mayContain, and your own allergen list from the labeling system, whose IDs are used in userAllergens.

Response (200 OK)

{
  "success": true,
  "builtInAllergens": ["Milk", "Eggs", "TreeNuts", "Peanuts", "Gluten", ...],
  "userAllergens": [
    { "id": 12, "name": "hazelnut", "isOther": false, "sortOrder": 0 },
    { "id": 15, "name": "Contains colourings that may affect activity and attention in children", "isOther": true, "sortOrder": 1 }
  ]
}

Your allergens are listed in your display order. For an “Other” entry (isOther: true), name holds the full warning text, which is printed as a separate statement on the label.


GET /api/ingredientgroups

Your sub-ingredient groups, whose IDs are used in a sub-ingredient’s groupId.

Response (200 OK)

{
  "success": true,
  "count": 2,
  "groups": [
    { "id": 3, "name": "Flavourings", "sortOrder": 0 },
    { "id": 4, "name": "Stabilisers", "sortOrder": 1 }
  ]
}

Collection Endpoints

Collections are the folders you use in the app to organise your recipes and ingredients. Each collection holds either recipes or ingredients (its type), never both, and a recipe or ingredient can be in several collections. These endpoints work on your own collections only; other users’ collections are never visible.

You can also read and set a single recipe’s or ingredient’s collections through the recipe and ingredient endpoints: their responses include collections, and POST and PUT accept collectionIds.

GET /api/collections

List your collections, sorted by name, with the number of recipes or ingredients in each.

Query Parameters

Parameter Values Description
type recipe or ingredient Optional, case-insensitive. Returns only collections of that type; omit it for both. Any other value → 400

Response (200 OK)

{
  "success": true,
  "count": 2,
  "collections": [
    {
      "id": 3,
      "name": "Gelato bases",
      "type": "Ingredient",
      "createdDate": "2026-02-01T09:15:00Z",
      "itemCount": 12
    },
    {
      "id": 7,
      "name": "Summer menu",
      "type": "Recipe",
      "createdDate": "2026-04-12T16:40:00Z",
      "itemCount": 5
    }
  ]
}

Collection Fields

Field Type Description
id integer Unique collection identifier
name string Collection name
type string "Recipe" or "Ingredient"
createdDate string (ISO 8601) When the collection was created
itemCount integer Number of recipes or ingredients in the collection

Example (curl)

curl -H "X-API-Key: icc_your_key" \
     "https://www.icecreamcalc.app/api/collections?type=recipe"

GET /api/collections/{id}

Get one collection with its recipes or ingredients.

Response (200 OK)

{
  "success": true,
  "collection": {
    "id": 7,
    "name": "Summer menu",
    "type": "Recipe",
    "createdDate": "2026-04-12T16:40:00Z",
    "itemCount": 2,
    "items": [
      { "id": 15, "name": "Mango Sorbet" },
      { "id": 12, "name": "Strawberry Gelato" }
    ]
  }
}

The collection fields are the same as in the list, plus items: the recipes (in a recipe collection) or ingredients (in an ingredient collection) it contains, sorted by name. Each id is a recipe ID or an ingredient ID that you can use with the recipe or ingredient endpoints. Ingredient names are the stored names (the English name for library ingredients).

Error Responses

HTTP Code Example Meaning
404 {"error": "Collection not found", "details": "..."} The collection doesn’t exist or belongs to another user

POST /api/collections

Create an empty collection.

Request

POST /api/collections
Headers:
  X-API-Key: icc_your_api_key_here
  Content-Type: application/json

{
  "name": "Summer menu",
  "type": "Recipe"
}

Request Fields

Field Type Required Constraints
name string Yes Leading and trailing spaces are removed; must then be non-empty and at most 100 characters. Must differ from the names of your other collections of the same type (a recipe collection and an ingredient collection may share a name)
type string Yes "Recipe" or "Ingredient", case-insensitive. It cannot be changed later

Response (201 Created)

The new collection, as GET /api/collections/{id} returns it, with a Location header pointing to it:

{
  "success": true,
  "collection": {
    "id": 8,
    "name": "Summer menu",
    "type": "Recipe",
    "createdDate": "2026-10-27T10:00:00Z",
    "itemCount": 0,
    "items": []
  }
}

Error Responses

HTTP Code Example Meaning
400 {"error": "Validation failed", "details": "Collection name is required"} Missing or over-long name, or a type that is not Recipe or Ingredient
409 {"error": "Duplicate collection name", "details": "You already have a recipe collection named 'Summer menu'"} You already have a collection of this type with this name

PUT /api/collections/{id}

Rename a collection. The body has one field, name, with the same rules as on POST. The type cannot be changed.

Request

PUT /api/collections/8
Headers:
  X-API-Key: icc_your_api_key_here
  Content-Type: application/json

{ "name": "Summer menu 2027" }

Response (200 OK)

The renamed collection with its items, as GET /api/collections/{id} returns it.

Error Responses

The same 400 and 409 errors as on POST, plus 404 “Collection not found” when the collection doesn’t exist or belongs to another user.


DELETE /api/collections/{id}

Delete a collection. The recipes or ingredients in it are not deleted; they are only removed from the collection. This cannot be undone.

Response (200 OK)

{
  "success": true,
  "message": "Collection deleted successfully",
  "collectionId": 8
}

Error Responses

404 “Collection not found” when the collection doesn’t exist or belongs to another user.


PUT /api/collections/{id}/items/{itemId}

Add a recipe to a recipe collection, or an ingredient to an ingredient collection. itemId is a recipe ID or an ingredient ID, depending on the collection’s type. Adding something that is already in the collection is not an error, so the call is safe to repeat.

You can add your own recipes, your personal ingredients and shared library ingredients. This is how you put a shared ingredient in one of your collections, since PUT /api/ingredients/{id} does not accept shared ingredients.

Request

PUT /api/collections/7/items/15
Headers:
  X-API-Key: icc_your_api_key_here

Response (200 OK)

The updated collection with its items, as GET /api/collections/{id} returns it.

Error Responses

HTTP Code Example Meaning
404 {"error": "Collection not found", "details": "..."} The collection doesn’t exist or belongs to another user
404 {"error": "Recipe not found", "details": "..."} or {"error": "Ingredient not found", "details": "..."} The recipe or ingredient doesn’t exist or isn’t available to you (temporary recipes included). An ingredient ID sent to a recipe collection is looked up as a recipe, and the other way round

DELETE /api/collections/{id}/items/{itemId}

Remove a recipe or ingredient from a collection. The recipe or ingredient itself is not deleted.

Response (200 OK)

The updated collection with its items, as GET /api/collections/{id} returns it.

Error Responses

HTTP Code Example Meaning
404 {"error": "Collection not found", "details": "..."} The collection doesn’t exist or belongs to another user
404 {"error": "Not in collection", "details": "Recipe with ID 15 is not in collection 7"} The recipe or ingredient is not in this collection

Example (Python)

import requests

BASE = "https://www.icecreamcalc.app/api"
headers = {"X-API-Key": "icc_your_key"}

# Create a recipe collection and put three recipes in it
r = requests.post(f"{BASE}/collections", headers=headers,
                  json={"name": "Summer menu", "type": "Recipe"})
r.raise_for_status()
collection_id = r.json()["collection"]["id"]

for recipe_id in [12, 15, 18]:
    requests.put(f"{BASE}/collections/{collection_id}/items/{recipe_id}",
                 headers=headers).raise_for_status()

Response Format

Success Response

Successful responses include a success field set to true. The exception is GET /api/recipes/test, which returns only message and timestamp.

{
  "success": true,
  // ... response data
}

Error Response

Error responses include success: false and error details:

{
  "success": false,
  "error": "Brief error message",
  "details": "More detailed explanation"
}

Two kinds of errors use the web framework’s standard “problem details” format instead (type, title, status, and for validation an errors object), without success, error or details:

  • 400 when the request body is not valid JSON, or a field has the wrong type (for example text in weight)
  • 403 from GET /api/recipes/{id}/calculated (title: "Forbidden")

HTTP Status Codes

Code Name Meaning
200 OK Request succeeded
201 Created Recipe, ingredient or collection created (POST /api/recipes, POST /api/ingredients, POST /api/collections)
400 Bad Request Invalid request data – see the error/details fields
401 Unauthorized Missing or invalid API key
403 Forbidden You don’t have access to this resource (or plan limit reached)
404 Not Found Resource doesn’t exist
409 Conflict The ingredient is recipe-derived and cannot be changed directly (PUT /api/ingredients/{id}), or you already have a collection of that type with that name (POST and PUT /api/collections)
429 Too Many Requests Rate limit exceeded – wait for the seconds in the Retry-After header
500 Server Error Server-side error occurred

Rate Limiting

The API enforces per-key rate limits to keep the service responsive for everyone. Limits use a fixed one-minute window:

Scope Limit Window
All API endpoints, per API key 100 requests 1 minute
POST /recipes/{id}/balance, per API key 20 requests 1 minute
Requests without an API key (shared) 10 requests 1 minute

The balance endpoint is computation-heavy, so it has its own tighter per-key limit (its calls are counted against the balance limit rather than the general one). Requests without an X-API-Key header share a single small bucket across all callers.

When you hit a limit

You’ll receive 429 Too Many Requests with a Retry-After header (seconds to wait) and an error body:

{
  "success": false,
  "error": "Rate limit exceeded",
  "details": "Too many API requests. Retry after 60 seconds (see the Retry-After header)."
}

Playing nicely

  • Cache responses when appropriate
  • Don’t make unnecessary repeated requests
  • On 429, wait for Retry-After before retrying – don’t hammer
  • Implement exponential backoff for retries

Note: The limits above are starting defaults and may be tuned. Read them from this page rather than hardcoding assumptions into your integration’s logic – and always honour Retry-After.


Data Access Rules

What You Can Access

  • ✅ Your own recipes (all types) – read and write
  • ✅ Your personal ingredients – read and write (recipe-derived ingredients: read only)
  • ✅ Shared/default ingredients (public database) – read only
  • ✅ Your own recipe and ingredient collections – read and write

What You Cannot Access

  • ❌ Other users’ recipes
  • ❌ Other users’ personal ingredients
  • ❌ Temporary recipes (not accessible via API – reading, updating, deleting, and balancing them all return errors)
  • ❌ Changing shared ingredients or recipe-derived ingredients, and deleting ingredients
  • ❌ Changing your allergen list, sub-ingredient groups or stock (they can be read, not written)

What You Can Do

  • ✅ Create recipes (POST /recipes) – counts against your plan’s recipe limit
  • ✅ Update recipes (PUT /recipes/{id}, full replacement)
  • ✅ Delete recipes (DELETE /recipes/{id})
  • ✅ Run Smart Balance (POST /recipes/{id}/balance) – returns a proposal, saves nothing
  • ✅ Create and update personal ingredients (POST /ingredients, PUT /ingredients/{id}), including sub-ingredients, your allergens, cost and physical state
  • ✅ Create, rename and delete your collections, and choose what is in them (/collections, or collectionIds on recipe and ingredient writes)
  • ✅ Store your own JSON in each recipe’s metadata field for syncing

Best Practices

Security

  • Always use HTTPS (required by the API)
  • Store API keys in environment variables, not in code
  • Never expose API keys in client-side JavaScript
  • Regenerate your key if compromised – remember the API can now modify and delete your recipes

Performance

  • Use /api/recipes/{id} when you need one recipe, not /api/recipes
  • Use the list endpoint’s externalId to match your system’s recipes in one call
  • Use /api/ingredients for browsing, /api/ingredients/{id} for details, and /api/ingredients?detail=full to sync your whole ingredient library in one request
  • Cache calculated data – it doesn’t change unless the recipe changes
  • Handle errors gracefully with retry logic, honouring Retry-After on 429

Updating safely

  • Because PUT is a full replacement, always GET first, modify, then PUT the whole recipe back
  • A PUT keeps ingredient locks, Smart Balance class chips, waste factors and displacement for ingredients that were already in the recipe
  • Use metadata (e.g. a version number) to detect concurrent changes from your own tooling
  • Preview balancing with POST /recipes/{id}/balance before committing weights with PUT
  • Ingredients work the same way: GET, modify, PUT the whole ingredient back. Leave out subIngredients and userAllergens when you only change values, so your label data stays as it is
  • Collections survive a round trip: the collections field in a GET response is ignored on PUT, and leaving out collectionIds keeps them

Error Handling

try {
    response = requests.get(url, headers=headers)
    response.raise_for_status()  # Raise exception for 4xx/5xx
    data = response.json()

    if not data.get('success'):
        print(f"API Error: {data.get('error')}")
        return None

    return data

except requests.exceptions.RequestException as e:
    print(f"Network error: {e}")
    return None

Changelog

October 2026

  • New collection endpoints: list, get, create, rename and delete your recipe and ingredient collections, and add or remove their recipes and ingredients (/collections)
  • Recipes: collections in GET /recipes, GET /recipes/{id} and the create and update responses; collectionIds on POST and PUT (on PUT, leaving it out keeps the collections); the create and update responses have a warnings field
  • Ingredients: collections in the GET /ingredients summaries; collectionIds on POST /ingredients and PUT /ingredients/{id}. collections is no longer read-only
  • PUT /recipes/{id}: recipes inside recipes can now go up to three levels deep, and loops are refused at every depth. The 400 “Invalid ingredient” details now name the chain or loop; before, any recipe-derived ingredient inside a recipe used as one was refused
  • Ingredient write endpoints: POST /ingredients (create) and PUT /ingredients/{id} (update, full replacement) for your personal ingredients, including sub-ingredients, your allergens, cost and physical state
  • GET /ingredients/{id}: new fields – subIngredients, userAllergens, customProperties, physicalState, isHidden, stock, collections, isShared, foodReferenceItemId, energyKcal and energyKJ
  • Documentation fix: another user’s ingredient returns 404; the 403 listed here before never occurred
  • GET /ingredients?detail=full: every ingredient with full details in one response
  • New read-only endpoints: GET /ingredients/lookups, GET /allergens and GET /ingredientgroups
  • PUT /recipes/{id}: keeps each existing ingredient line’s lock, Smart Balance class chip, waste factor and displacement (lines are paired by ingredient, in recipe order); before, a PUT cleared them
  • Recipe requests: an omitted or null amountMin / amountMax now means 0 and 1 (no range), as in the app; before, an omitted amountMax was stored as 0
  • Responses: the legacy fields isOverride and overrideSourceId are removed

September 2026

  • GET /ingredients/{id}: new fields – the nine sugar composition fields (sucrose, glucose, fructose, maltose, galactose, trehalose, otherSugars, maltotriose, higherOrderSugars; null = not recorded), displacement, defaultScale, and custom1–custom10
  • GET /recipes/{id}/calculated: numericValue is now null when a value is undefined (such as Fat/Protein without protein); recipes without protein no longer fail with a 500
  • GET /recipes/{id}/calculated: new values – Sucrose, Glucose, Fructose, Maltose, Galactose, Trehalose, Other sugars, Unassigned sugars, Maltotriose, Higher order sugars, Sugar composition coverage and Perceived sweetness; Australia/New Zealand label values lblAU_* (August)
  • GET /recipes/{id}/calculated: Serving temp, Serving temp gelato and Extraction temp now use your frozen-water settings instead of fixed 75%, 69% and 55%
  • PUT /recipes/{id}: returns 400 “Invalid ingredient” when the update would put a recipe-derived ingredient inside a recipe that is itself used as one; the 403 “Recipe edit restricted” response (in place since late July, replacing a 500) is now documented
  • Authentication: API keys of accounts locked by an administrator are rejected with 401

Version 1.1 (July 2026)

  • Recipe write endpoints: create (POST), update (PUT, full replacement), delete (DELETE)
  • New recipe fields: notes, externalId, category, tags, bestBeforeDays, overrun
  • Opaque metadata JSON store per recipe for external-system syncing
  • List endpoint additions: info and externalId (description kept as a legacy alias)
  • Smart Balance endpoint: POST /recipes/{id}/balance (non-mutating proposal with full diagnostics)
  • Per-key rate limiting (100/min general, 20/min balance) with 429 + Retry-After

Version 1.0 (November 2025)

  • Initial API release
  • Recipe endpoints (list, single, calculated)
  • Ingredient endpoints (list, single)
  • API key authentication

Need Help?

  • 📘 Getting Started Guide – Learn how to generate an API key and make your first request
  • 🔬 API Test Page – Test endpoints interactively from your Account page
  • 📧 Support – Contact us if you encounter issues

💡 Pro Tip: Use the API Test Page (accessible from your Account page) to explore endpoints and see example responses before writing code!


REST API
API reference
endpoints
authentication
recipes
ingredients
calculated data
Smart Balance
HTTP methods
JSON
rate limiting
error handling
API key
integration
developer documentation
technical reference
create ingredient
update ingredient
sub-ingredients
allergens
ingredient groups
collections
recipe collections
ingredient collections
⚡

Connection Lost

Attempting to reconnect to the server...

An error has occurred. This application may no longer respond until reloaded. Reload 🗙