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 onGET /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, ornullwhen the value is undefined for this recipe, for exampleFat/Proteinin a recipe without protein. For temperatures it is in °C, or in °F if your account shows temperatures in Fahrenheitinfo– 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:
SucrosetoOther sugarscome from ingredients with a recorded sugar composition;Unassigned sugarsis the non-lactose sugar of the other ingredients, so these seven plusLactoseandUnassigned sugarsadd up toTotal sugars.Sugar composition coverageshows how much of the total sugars is accounted for.Perceived sweetnessis on the POD scale, for comparison withPOD. 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 with400. 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
metadataon aPUTclears 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, thenPUTthe 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– withstatus: "Converged", amessageexplaining the early exit (e.g. “Chart has no balancing targets”), unchanged weights, and emptytargets/iterations/ingredientsarrays. Check themessagefield, not just the status.
Typical agent workflow
POST /api/recipes/1/balance– get the proposal- Inspect
status,wasReverted, the per-target achievability, and the per-ingredientfinalWeightvalues - If you like the proposal:
GET /api/recipes/1, set each ingredient’sweightto the proposal’sfinalWeight, andPUTthe recipe back - 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=fullto get every ingredient with all the fields ofGET /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.035means 3.5 g per 100 g; small nutrients use the same scale (sodium: 0.0004is 40 mg per 100 g). Multiply by 100 for grams per 100 g.
Basic Information:
id,name,category,info,created,changedsource– “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 (nullotherwise)isShared–truefor shared library ingredients, which you cannot change through the APIfoodReferenceItemId– the linked food reference database entry, ornull
Nutritional Data:
water,totalFat,saturatedFat,transFat,cholesterolsodium,salt,carbohydrates,fiber,totalSugars,addedSugarsprotein,alcohol,lactose,polyolsvitaminD,calcium,iron,potassiumenergyKcal,energyKJ– energy per 100 g, calculated from the composition (read-only)
Sugar Composition:
sucrose,glucose,fructose,maltose,galactose,trehalose,otherSugars– together withlactose, these are part oftotalSugarsmaltotriose,higherOrderSugars– outsidetotalSugarsnullmeans the value is not recorded for this ingredient;0means it is known to be absent. See Sugar Composition
Ice Cream Properties:
msnf– Milk solids non-fatmilkSolids– Total milk solids (msnf+milkFat)milkProtein– Milk protein contentmilkFat,cocoaFat,cocoaSolidstotalSolids,otherSolidspac– Freezing point depression (pure sucrose = 1.0)pod– Relative sweetness (pure sucrose = 1.0)hf– Hardening factorstabilizers,emulsifiersamountMin,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;nullwhen 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 onecustom1…custom10– your custom property values, on the same scale as the composition valuescustomProperties– 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/lookupslists all ten slotsphysicalState–"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 contentisHidden–truewhen you have hidden the ingredient from your ingredient lists in the appstock– your stock record, ornullwhen you have none:stockWeight(grams,nullwhen not tracked),lowStockThreshold(grams),lastRestocked,expiryDate,storageType(Ambient,ChilledorFrozen) andnotes. Read-onlycollections– your ingredient collections that contain this ingredient, as{ "id": 3, "name": "Gelato bases" }. To change them, sendcollectionIdswithPOSTorPUT, or use the collection endpoints
Allergen Information:
contains– Array of allergens this ingredient containsmayContain– 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 hasid,name,percentage(0–100, share of the ingredient),isHighlighted(printed in capitals, typically allergens),showPercentage(the percentage is printed after the name),groupIdandgroupName(one of your sub-ingredient groups, ornull), andsortOrderuserAllergens– the allergens from your own allergen list assigned to this ingredient. Each hasuserAllergenId,name,isOther,containsandtracesOf. A normal allergen has exactly one ofcontainsandtracesOfset; for an “Other” warning (isOther: true) both arefalseand 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
nullfor the sugar composition). The exceptions arecostKg,physicalState,subIngredients,userAllergensandcollectionIds: leaving those out (or sendingnull) keeps what is there. AlwaysGETthe 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:
400when the request body is not valid JSON, or a field has the wrong type (for example text inweight)403fromGET /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 forRetry-Afterbefore 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, orcollectionIdson recipe and ingredient writes) - ✅ Store your own JSON in each recipe’s
metadatafield 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
externalIdto match your system’s recipes in one call - Use
/api/ingredientsfor browsing,/api/ingredients/{id}for details, and/api/ingredients?detail=fullto 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-Afteron429
Updating safely
- Because
PUTis a full replacement, alwaysGETfirst, modify, thenPUTthe whole recipe back - A
PUTkeeps 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}/balancebefore committing weights withPUT - Ingredients work the same way:
GET, modify,PUTthe whole ingredient back. Leave outsubIngredientsanduserAllergenswhen you only change values, so your label data stays as it is - Collections survive a round trip: the
collectionsfield in aGETresponse is ignored onPUT, and leaving outcollectionIdskeeps 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:
collectionsinGET /recipes,GET /recipes/{id}and the create and update responses;collectionIdsonPOSTandPUT(onPUT, leaving it out keeps the collections); the create and update responses have awarningsfield - Ingredients:
collectionsin theGET /ingredientssummaries;collectionIdsonPOST /ingredientsandPUT /ingredients/{id}.collectionsis 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. The400“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) andPUT /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,energyKcalandenergyKJ- Documentation fix: another user’s ingredient returns
404; the403listed here before never occurred GET /ingredients?detail=full: every ingredient with full details in one response- New read-only endpoints:
GET /ingredients/lookups,GET /allergensandGET /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
nullamountMin/amountMaxnow means 0 and 1 (no range), as in the app; before, an omittedamountMaxwas stored as 0 - Responses: the legacy fields
isOverrideandoverrideSourceIdare 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, andcustom1–custom10GET /recipes/{id}/calculated:numericValueis nownullwhen a value is undefined (such asFat/Proteinwithout protein); recipes without protein no longer fail with a500GET /recipes/{id}/calculated: new values –Sucrose,Glucose,Fructose,Maltose,Galactose,Trehalose,Other sugars,Unassigned sugars,Maltotriose,Higher order sugars,Sugar composition coverageandPerceived sweetness; Australia/New Zealand label valueslblAU_*(August)GET /recipes/{id}/calculated:Serving temp,Serving temp gelatoandExtraction tempnow use your frozen-water settings instead of fixed 75%, 69% and 55%PUT /recipes/{id}: returns400“Invalid ingredient” when the update would put a recipe-derived ingredient inside a recipe that is itself used as one; the403“Recipe edit restricted” response (in place since late July, replacing a500) 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
metadataJSON store per recipe for external-system syncing - List endpoint additions:
infoandexternalId(descriptionkept 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!