8 Free Food & Nutrition APIs (No Key, Tested 2026)
1. Open Food Facts: the barcode lookup that returns 200 on a missing product
Open Food Facts is a crowd-sourced database of packaged food. Give the v2 product endpoint a barcode and it returns the product with a nested nutriments object. This is the anchor of the whole post, so look closely at what it does when the barcode is real and when it is not.
# runnable, read-only
curl "https://world.openfoodfacts.org/api/v2/product/3017624010701.json" # Nutella
curl "https://world.openfoodfacts.org/api/v2/product/00000000.json" # garbage
// real barcode 3017624010701 :
{
"code" : "3017624010701",
"product" :{ "nutriments" :{ ... }},
"status" : 1
}
// invalid barcode 00000000 :
{
"code" : "00000000",
"status" : 0,
"status_verbose" : "no code or invalid code"
}
Both requests returned HTTP 200. The only thing that separates a hit from a miss is status in the body: 1 means found, 0 means nothing. There is no 404 to catch.
It gets worse if you ask for specific fields. When I requested ?fields=nutrition_data_per on the Nutella barcode, I got back {"product":{},"status":1,"status_verbose":"product found"}: status says found, and product is an empty object. A 200, a "found," and no data. If your code does resp.json()["product"]["nutriments"] on that, it throws a KeyError on a request that technically succeeded.
Trust the status field, not the status code. Docs at openfoodfacts.github.io/openfoodfacts-server/api.
When to use it: any barcode-to-nutrition lookup for packaged food. It is the front door of the whole ecosystem.
2. Open Food Facts again: the units trap (kJ vs kcal, per-100g vs per-serving)
Yes, this is the same endpoint. It earns a second slot because the nutriments object fails a second, sneakier way, and this one does not throw at all. It just gives you a plausible wrong number.
I pulled Coca-Cola, barcode 5449000000996.
# runnable, read-only
curl "https://world.openfoodfacts.org/api/v2/product/5449000000996.json"
{
"nutriments" :{
"energy" : 180,
"energy-kcal" : 42,
"energy-kj" : 180,
"energy-kcal_serving" : 139,
"energy-kj_serving" : 594,
"carbohydrates" : 10.6,
"carbohydrates_serving" : 35
}
}
Read energy and you get 180. Looks like calories. It is not. That 180 is kilojoules; the calorie value is energy-kcal, which is 42. Grab the naive energy field as "calories per 100g" and your tracker is wrong by a factor of about 4.3 on every Coke in the database.
The same field lives four ways at once: _100g, _serving, kJ, and kcal. And notice carbohydrates is 10.6 (per 100g) while carbohydrates_serving is 35 (a full can). Mix the basis and you are comparing a mouthful to a bottle.
Pick your unit and your basis explicitly, then normalize before you store anything.
When to use it: whenever you actually read nutrient values, which is always. This is where calorie apps quietly ship the wrong number.
3. Open Pet Food Facts: the same engine, a different status code
Open Pet Food Facts is the sibling database for pet food, same v2 product API. I looked up a barcode it does not have.
# runnable, read-only
curl -i "https://world.openpetfoodfacts.org/api/v2/product/3182550716291.json"
// HTTP/2 404
{
"code" : "3182550716291",
"status" : 0,
"status_verbose" : "product not found"
}
Here is the twist that makes hardcoding dangerous. The main Open Food Facts endpoint returned 200 on a not-found product (entry 1). This one, same engine, returned 404 on a not-found product. Two databases, one codebase, two different transport signals for the exact same situation.
What is identical across both is the body: status:0. So if you wrote if resp.status_code == 200: assume_found(), it works on pet food and silently breaks on food. The body status field is the one signal that holds across the whole family.
Docs at openpetfoodfacts.org.
When to use it: pet-food barcode lookups, and as your reminder that status codes are not consistent even within one project.
4. Open Products Facts: 404 again, so stop hardcoding the status
Open Products Facts covers non-food consumer products. Same API, and it confirms the lesson from entry 3.
# runnable, read-only
curl -i "https://world.openproductsfacts.org/api/v2/product/3661112538014.json"
// HTTP/2 404
{
"code" : "3661112538014",
"status" : 0,
"status_verbose" : "product not found"
}
Another 404, not a 200, on not-found. So now you have concrete proof from three sibling databases that you cannot assume "this API always returns 200" or "this API always returns 404." The transport code depends on which sibling you hit. Read status from the body every time.
Docs at openproductsfacts.org.
When to use it: barcode lookups for household and non-food goods.
5. Open Beauty Facts: status 1, and almost nothing in the record
Open Beauty Facts is the cosmetics sibling. It is not food, and I am flagging that plainly, but it belongs here because it shows the family's other failure mode: a "found" record that is nearly empty.
# runnable, read-only
curl "https://world.openbeautyfacts.org/api/v2/product/3600542525701.json"
{
"code" : "3600542525701",
"product" :{
"product_name" : "Shampooing"
},
"status" : 1,
"status_verbose" : "product found"
}
status:1, "product found," and the product is one field: a name, "Shampooing." No ingredients, no detail. This is the sparse-record trap. On crowd-sourced data, "found" means someone scanned a barcode once, not that anyone filled in the fields you need. A completeness check (does the field I want actually exist and is it non-empty) is a separate test from the status check.
Docs at openbeautyfacts.org.
When to use it: cosmetics lookups, and as a warning that status:1 is a floor, not a guarantee of complete data.
6. Open Food Facts Prices: a 200 full of nulls
Prices is a newer Open Food Facts service where people log what products cost. Different host, its own v1 API, still keyless.
# runnable, read-only
curl "https://prices.openfoodfacts.org/api/v1/prices?size=1"
{
"items" :[{
"id" : 1,
"product" :{
"product_name" : null,
"brands" : null,
"nutriscore_grade" : null,
"categories_tags" :[]
}
}]
}
HTTP 200, valid JSON, an items array with a real row. And the joined product object is almost entirely null: no name, no brand, no grade, an empty categories_tags array. The price record exists; the product it points to was never enriched. This is the classic "200, but the fields are empty" case. A null-coalescing default on every joined field is not optional here, it is the difference between a working feature and a page full of "null."
Docs at prices.openfoodfacts.org.
When to use it: crowd-sourced grocery-price features, with heavy null guards on the joined product.
The two keyless APIs that aren't Open Food Facts
Out of eight keyless entries, exactly two come from a different codebase. One of them handles errors better than the whole Open Facts family. The other inherits Open Food Facts' data and adds a fresh bug on top.
7. Fruityvice: the one that gets not-found right
Fruityvice returns nutrition for whole fruits, keyed by name. It is small and single-purpose, and it is the counter-example that proves good behavior is possible.
# runnable, read-only
curl "https://www.fruityvice.com/api/fruit/banana" # 200
curl -i "https://www.fruityvice.com/api/fruit/pizza" # 404
// banana, HTTP 200 :
{
"name" : "Banana",
"nutritions" :{
"calories" : 96,
"fat" : 0.2,
"sugar" : 17.2,
"carbohydrates" : 22.0,
"protein" : 1.0
}
}
// pizza, HTTP 404 :
{
"error" : "Not found"
}
A miss returns an honest 404 with {"error":"Not found"}, not a 200 with a hidden flag. Refreshing after five endpoints that lie about status.
But it hides a different assumption: the serving basis is never stated. Those numbers are per 100 grams (a banana is not 96 calories, a 100g portion of banana is), and nothing in the payload says so. Bake in the wrong basis and every fruit in your tracker is off. A silent assumption is still a bug; it just fails quietly instead of throwing.
Docs at fruityvice.com.
When to use it: fast fruit-nutrition lookups, once you have confirmed and hardcoded the per-100g basis.
8. Wger: 1.3M ingredients, every nutrient a string
Wger is an open-source workout and nutrition manager with a public REST API. The ingredient endpoint is large and keyless. Two calls below: one for the catalog size, one for a single ingredient pinned by id so you get the exact same row I did.
# runnable, read-only
curl "https://wger.de/api/v2/ingredient/?limit=1&language=2" # English catalog size
curl "https://wger.de/api/v2/ingredient/33208/" # one ingredient, pinned by id
// list endpoint, count only (results come back unordered, so I pin an id below):
{
"count" : 1358809,
"results" :[ ... ]
}
// ingredient 33208, fully reproducible:
{
"name" : "Flammekueche",
"energy" : 219,
"protein" : "6.100",
"carbohydrates" : "23.100",
"fiber" : null,
"is_vegan" : null,
"source_name" : "Open Food Facts"
}
That count is 1,358,809 English ingredients. Drop language=2 and it jumps to roughly 3 million rows across all languages, so the "how big is it" number depends entirely on the filter you send. Either way it sounds like a rival to Open Food Facts, until you read source_name: "Open Food Facts."
Wger re-imports the same data. So it inherits Open Food Facts' gaps (fiber and is_vegan come back null) and adds one of its own: the nutrients are strings. "6.100", not 6.1. Try to add two of those in Python or JavaScript and + concatenates them into "6.10023.100" instead of summing. Cast every nutrient to a number before you do arithmetic.
One more trap the list endpoint hides: it returns rows in no guaranteed order, so a bare ?limit=1 hands you a different ingredient on every call, which is why I pinned id 33208 instead of trusting "the first row."
This is the downstream lesson: when one dataset re-imports another, the original's defects flow through and the new layer can add fresh ones. Wger also serves a separate workout catalog if you need exercises.
Docs at wger.de.
When to use it: ingredient search at volume, or workout data, with a hard cast on every numeric field.
Three bonus food and recipe APIs that need a shared key
These three are genuinely useful and genuinely popular, so people search for them. But they are not clean keyless: TheMealDB and TheCocktailDB run on a shared public key (1), and USDA FoodData Central requires a free API key. They are listed here because they are often grouped with keyless APIs in roundups, but they each require you to pass a key parameter in every request.
Comments
No comments yet. Start the discussion.