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 |
/ingredients/{id} |
GET | Get a single ingredient with full details |
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
}
]
}
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 |
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
}
]
}
}
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) |
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",
"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 |
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} */ }
}
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 |
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 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) |
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 */ }
}
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": "'Chocolate Base' is used as a recipe-derived ingredient, so it cannot contain one. Recipes inside recipes go one level deep."} |
The recipe is the source of a recipe-derived ingredient, and the update would add a recipe-derived ingredient to it. 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
The ingredient endpoints are read-only: you can list and read ingredients, but not create or change them through the API. 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.
Note: This endpoint returns basic information only. For full nutritional data, use
/api/ingredients/{id}
Request
GET /api/ingredients
Headers:
X-API-Key: icc_your_api_key_here
Response (200 OK)
{
"success": true,
"count": 153,
"ingredients": [
{
"id": 1,
"name": "Whole Milk",
"category": "Dairy",
"source": "Default",
"isRecipeDerived": false,
"created": "2024-01-01T00:00:00Z",
"changed": "2024-01-01T00:00:00Z"
},
{
"id": 42,
"name": "My Custom Sugar",
"category": "Sugar",
"source": "Personal",
"isRecipeDerived": false,
"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 |
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
GET /api/ingredients/{id}
Retrieve complete details for a single ingredient including all nutritional data and ice cream properties.
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
// Allergens
"contains": ["Milk"],
"mayContain": [],
"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)
Nutritional Data:
water,totalFat,saturatedFat,transFat,cholesterolsodium,salt,carbohydrates,fiber,totalSugars,addedSugarsprotein,alcohol,lactose,polyolsvitaminD,calcium,iron,potassium
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 values. Their names are your own custom property names and are not part of the response
Allergen Information:
contains– Array of allergens this ingredient containsmayContain– Array of potential cross-contamination allergens
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");
}
});
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}/calculatedandGET /api/ingredients/{id}(title: "Forbidden")
HTTP Status Codes
| Code | Name | Meaning |
|---|---|---|
200 |
OK | Request succeeded |
201 |
Created | Recipe created (POST /api/recipes) |
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 |
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, including recipe-derived ingredients – read only
- ✅ Shared/default ingredients (public database) – read only
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)
- ❌ Creating, changing or deleting ingredients (the ingredient endpoints are read-only)
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 - ✅ 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 - 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
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
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!