The Foodlex API

A recipe goes in as JSON, the calculated values come back as JSON: nutrition values, allergens and the finished ingredient list. Built for your ERP, your shop or a tool of your own — wherever the numbers are needed without anyone retyping them into the app.

Three endpoints

All under https://app.foodlex.app/api/v1/, all POST.

EndpointResponse
/v1/nutrition Nutrition values per 100 g, per portion and raw — unrounded so you can carry on calculating, plus the rounded label figures
/v1/allergens The fourteen allergen groups of Annex II to Regulation (EU) No 1169/2011, with the position in the text
/v1/ingredient-list The ingredient list in descending order of weight, with allergen emphasis and quantitative declaration (QUID)

What it does not do

The API is stateless: it creates no recipe, reads none and changes nothing in your data. Every call stands on its own — what goes in is calculated, and nothing is left behind.

It does not look up foods either. You supply the nutrition values for each ingredient. That is deliberate: a figure we quietly add from a database ends up on a label somebody else has to answer for. If you want the database, use the app.

Authentication

One key, one header:

Authorization: Bearer flx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The key is flx_live_ plus 32 characters. It is shown exactly once, at creation — only its SHA-256 is stored and it cannot be recovered from the database. If you lose it, revoke it and get a new one.

Keep the key on a server, not in a web page. A key in browser JavaScript is a key given away to every visitor. And there is deliberately no query parameter: a key in a URL ends up in every access log and every referrer.

One complete call

curl -sS https://app.foodlex.app/api/v1/nutrition \ -H "Authorization: Bearer $FOODLEX_KEY" \ -H 'Content-Type: application/json' \ -d '{ "market": "DE", "language": "en", "preparationLossPercent": 12, "portions": 8, "ingredients": [ { "name": "Wheat flour, type 405", "amountGrams": 500, "nutrients": { "energyKj": 1443, "energyKcal": 341, "fat": 1.1, "fatSaturated": 0.2, "carbs": 71.8, "sugar": 1.2, "protein": 10.6, "salt": 0.01 } }, { "name": "Butter", "amountGrams": 250, "nutrients": { "energyKj": 3059, "energyKcal": 741, "fat": 82.0, "fatSaturated": 52.0, "carbs": 0.6, "sugar": 0.6, "protein": 0.7, "salt": 0.02 } } ] }'

The response, shortened:

{ "apiVersion": "1", "market": "DE", "language": "en", "weights": { "rawGrams": 750, "finalGrams": 660, "portionGrams": 82.5 }, "per100g": { "energyKj": 2251.8939393939395, "energyKcal": 539.0151515151515, … }, "perPortion":{ "energyKj": 1857.8125, … }, "labelPer100g": { "energyKj": "2,252 kJ", "energyKcal": "539 kcal", "fat": "32 g", "fatSaturated": "20 g", "carbs": "55 g", "sugar": "1.1 g", "protein": "8.3 g", "salt": "0.02 g" }, "warnings": [] }
per100g is unrounded, with every digit. That is deliberate: if you carry on calculating — to a different serving size, into a table of your own — you should not start from a rounded intermediate value. Rounding happens once, at the end, and that is what labelPer100g holds.
Two places where a program trips. Number and unit are separated by a non-breaking space (U+00A0; the French thousands separator is U+202F) — splitting "32 g" on an ordinary space finds nothing. And labelPer100g exists only for DE, FR and GB, the three under EU law: Australia rounds to three significant figures, the US in steps relative to a RACC serving size. There the field is absent and labelNote says why, in your language — a wrongly rounded figure looks right. The values in per100g are correct for every market.

Markets and languages

The request carries both, and both cover everything the app covers:

market DE · FR · AU · US · GB — decides the rule set. Default DE
language de · en · fr — affects messages, designations and the number format. Default de

The allergen identifiers stay English even in a German response: they are keys, not display text. The readable name sits beside them, per market and language.

Limits and quota

Body1 MB
Ingredients per request200
Text for /v1/allergens20,000 characters
Calls per monthFree 0 · Starter 0 · Pro 2,000 · Team 20,000

The count is per workspace, not per key: the quota belongs to the plan, and two keys of the same business should not buy each other extra room.

Errors

One shape, on every endpoint, at every status:

{ "apiVersion": "1", "error": { "code": "bad_request", "message": "The request does not match the schema: …", "field": "ingredients[2].amountGrams" } }
StatuscodeWhen
400bad_requestThe body is not JSON or does not match the schema
401unauthorizedNo key, unknown, revoked or expired
403forbiddenThis workspace's plan excludes the API
405method_not_allowedEvery endpoint is POST
413payload_too_largeBody larger than 1 MB
429quota_exceededMonthly quota used up
500internal_errorUnexpected
503unavailableThe key check cannot be reached

Versioning

The version sits in the path, not in a header — so it shows up in every error message, every log and every line of example code.

PromisedA field documented in v1 does not disappear and does not change its meaning
PromisedAn unknown field in the request is ignored, not rejected
AllowedNew fields in the response — a client must skip over fields it does not know
Not promisedThe order of keys in the JSON. Nor the numbers themselves: if a rounding rule or an allergen pattern is corrected, the result changes — that is a bug fix, not a broken contract

If a field has to change its meaning, /api/v2/… appears beside it.

Getting a key

The API is included in the Pro plan and costs nothing on top. Write to me with what you want to use it for, and I will create the key and send it by a route that does not leave it sitting in a mailbox.

The responsibility stays yours: Foodlex does the arithmetic and the formatting, and it says where each figure comes from. The professional and legal check before printing stays with whoever places the label on the market.