Querying & data model
This page covers how to read data: which resources you can query, the envelope they come back in, and how to combine the four levers (search, filter, sort and shape) to get exactly what you need. For the full per-endpoint reference, see the API explorer.
The resources
| Resource | Endpoint | What it is |
|---|---|---|
| Foods | GET /v1/foods | The core resource: foods and ingredients |
| Food by id | GET /v1/foods/{id} | A single food by its 12-char id |
| Nutrients | GET /v1/nutrients | The nutrient catalog (e.g. PROTEIN) |
| Brands | GET /v1/brands | Brand catalog |
| Food groups | GET /v1/food-groups | Food-group catalog |
The envelope
Every response is wrapped. Nothing is ever returned as a bare array or a bare object, so one parser handles every endpoint.
A single resource comes back as { "data": { … } }: one key, holding the
record.
A list adds two siblings:
{ "data": [ { "id": "TIgbNPnzCIjX", "name": "Roasted salted almonds", … } ], "links": { "self": "/v1/foods?q=almonds", "next": "/v1/foods?q=almonds&cursor=b3V0..." }, "meta": { "has_more": true, "page_size": 10 } }
| Key | What it holds |
|---|---|
data | The rows. Always an array on a list endpoint, even for one result or none |
links.self | The request you just made, echoed back |
links.next | The URL for the next page, or null when you are on the last one |
meta.has_more | true while pages remain, the same signal as links.next being set |
meta.page_size | How many rows a full page holds, so you can tell a short page from the end |
Follow links.next rather than building it yourself: it already carries your
q, filters, sort and cursor. See Paginate.
Every food in data carries the same flat core (id, barcode, name,
description, scientific_name, country_of_origin, ingredients_text,
is_foundational, basis_unit, brand_id, food_group_id) with null where a
value is unknown rather than the key being dropped. Related objects are opt-in
via include=, covered in Shape the response, and
basis_unit is the one to know first: it is what every nutrient value is
measured against (Nutrition data).
Search
Pass ?q= to search the catalog. Results come back best match first, and the
search is typo-tolerant, so close-but-misspelled words still find the right food:
curl -G https://api.noms.sh/v1/foods \ --data-urlencode "q=amonds" \ -H "Authorization: Bearer $NOMS_KEY"
Filter
Filter by naming the field, then the operator in brackets: field[op]=value.
The most common is an exact match, e.g. resolving a barcode:
/v1/foods?barcode[eq]=00041570110645A bare field=value means eq, so this is the same request:
/v1/foods?barcode=00041570110645Repeat the pattern to combine filters. They all have to hold at once:
/v1/foods?food_group_id=DAIRY&is_foundational=falseWhat each endpoint filters by
| Endpoint | Fields | Operators |
|---|---|---|
/v1/foods | id, barcode, name, brand_id, brand_name, food_group_id, country_of_origin, market_countries | eq, in |
/v1/foods | is_foundational | eq |
/v1/foods | nutrient[CODE] | eq, gt, gte, lt, lte |
/v1/brands | id, name | eq, in |
/v1/food-groups | id, name | eq, in |
/v1/nutrients | id, name, unit | eq, in |
in takes a comma-separated list, so ?food_group_id[in]=DAIRY,NUTS_AND_SEEDS
matches either group. Two of those fields read through a relation rather than a
column on the food: brand_name matches the brand's own name, and
market_countries tests membership, so ?market_countries=CL keeps every food
sold in Chile.
Filtering on a nutrient value
nutrient[CODE][op]=value compares a food's per-100 value for one nutrient,
where CODE is any id from GET /v1/nutrients. Repeat the parameter to stack
constraints, and use two on the same code to express a range:
/v1/foods?nutrient[PROTEIN][gte]=20&nutrient[TOTAL_SUGARS][lt]=5Barcodes are 14-digit GTINs
Barcodes are stored and returned as a 14-digit GTIN, zero-padded on the left.
Filtering normalizes your value to that form first, so any width of the same
code resolves the same food. 041570110645, 0041570110645 and
00041570110645 all find it. Hand the filter whatever the scanner gives you.
Filters skip missing values
A comparison only matches rows that have a value. On a field that can be null,
is_foundational[eq]=false returns the foods explicitly marked false, not the
ones where it was never determined.
Sort
Sort with ?sort=. A bare field name sorts ascending; prefix it with - for
descending:
/v1/foods?sort=-nameWhat each endpoint sorts by
| Endpoint | Sort keys | Default order |
|---|---|---|
/v1/foods | id, name, brand_name, nutrient[CODE] | id |
/v1/brands | id, name | id |
/v1/food-groups | id, name | id |
/v1/nutrients | id, name, unit | name |
Comma-join keys to add tie-breakers, applied left to right: ?sort=brand_name,name.
Every sort ends on id whether you name it or not, so the order is always total
and a page boundary never lands mid-tie. A key that is not in the table returns
400 /problems/invalid-sort, whose body lists the ones that would have worked.
nutrient[CODE] ranks foods by their per-100 value for that nutrient, which
makes "highest protein first" a single parameter:
/v1/foods?sort=-nutrient[PROTEIN]A sort alongside q is a tie-breaker, not an override. With both, results
are ordered by how well they match, and sort only separates equally-good
matches. To order strictly by a column, leave q out.
Sorting by something a food lacks puts it last, not out.
?sort=-nutrient[FIBER] returns every food the query matched. The ones with no
fibre value recorded simply come after the ones that have it. Same for
?sort=brand_name and unbranded foods. Adding a sort never shrinks your result
count.
Shape the response
Two parameters decide what comes back. include= adds whole related objects, and
fields[...]= trims any resource to the columns you name. Together they let you
fetch exactly what you render.
include
include= grafts related objects onto each food. Combine them with commas:
/v1/foods/TIgbNPnzCIjX?include=brand,nutrients,serving_sizesA food exposes six relations: brand, food_group, nutrients,
serving_sizes, images and market_countries. Without include= a food
carries only its flat core, with brand_id and food_group_id as the handles
you would follow.
fields[...]
fields[<resource>]= is a sparse fieldset: a comma-separated list of the columns
that resource should return. The key is the resource's own name, not the
field you are trimming, and each resource in the response has its own key:
| Key | Trims | Columns you can name |
|---|---|---|
fields[foods] | The food itself | id, barcode, name, description, scientific_name, country_of_origin, ingredients_text, is_foundational, basis_unit, brand_id, food_group_id, plus any relation name |
fields[brands] | brand | id, name |
fields[food-groups] | food_group | id, name, icon_url |
fields[nutrients] | nutrients | id, name, unit, value |
fields[serving_sizes] | serving_sizes | unit, quantity, grams, milliliters, descriptor, is_default |
fields[images] | images | type, url |
fields[market_countries] | market_countries | country_code |
The top-level key gates the nested ones too. An included relation is itself a
field of the food, so a relation you include= but leave out of fields[foods]
is dropped from the response. Name it there to keep it, then trim its own columns
with its own key. Below, brand survives because fields[foods] lists it, and
food_group does not:
curl -G https://api.noms.sh/v1/foods/TIgbNPnzCIjX \ --data-urlencode "include=brand,food_group" \ --data-urlencode "fields[foods]=id,name,basis_unit,brand" \ --data-urlencode "fields[brands]=name" \ -H "Authorization: Bearer $NOMS_KEY"
Naming a column that the resource does not have returns
400 /problems/invalid-fields, and the body lists the ones it does.
Paginate
Lists are keyset (cursor) paginated, so they stay fast and stable even on
large result sets. A page holds 10 rows by default and 20 at most; set it
with page_size, then follow links.next until it is null:
let url = "https://api.noms.sh/v1/foods?q=oat&page_size=20"; const all = []; while (url) { const res = await fetch(url, { headers: { "X-API-Key": process.env.NOMS_KEY } }); const page = await res.json(); all.push(...page.data); url = page.links.next; // null on the last page }
Skip a page you already have
Catalog responses carry an ETag: a fingerprint of that exact response. Send it back
as If-None-Match and you get 304 Not Modified with no body when nothing has
changed, instead of the whole page again.
const url = "https://api.noms.sh/v1/foods?q=oat"; const headers = { "X-API-Key": process.env.NOMS_KEY }; const first = await fetch(url, { headers }); const etag = first.headers.get("ETag"); // later, for the same URL const again = await fetch(url, { headers: { ...headers, "If-None-Match": etag } }); if (again.status === 304) { // nothing changed - keep what you already parsed }
The tag covers everything the response depends on — your tier and market, include,
fields, sort, and the page cursor. A tag from one request is valid only for that
same request. Reuse it on a different one and you get the full 200, never the wrong
page.
Responses are also cacheable by your own client for 300 seconds
(Cache-Control: private, max-age=300). Inside that window a repeat request never
leaves your process. Revalidation is what happens after it.
On the free Taster tier, /v1/foods is not cacheable and carries no tag, because
its results are scoped to your chosen market and that choice can change. The nutrient,
brand and food-group catalogs are cacheable on every tier.
A 304 still counts against your quota
The request reached the API, so it is metered and rate-limited exactly like a 200,
and the RateLimit-* headers come back with it. What revalidation saves you is
bandwidth and time, not requests. See Rate limits & quotas.
Choosing your market
The free Taster tier serves one market: a single country's foods. The choice
belongs to your account, not to a key. Every key you hold serves the same market,
and changing it moves all of them at once. Pick it in your dashboard, or read your
current selection and the markets you can choose from with GET /v1/market:
curl https://api.noms.sh/v1/market \ -H "Authorization: Bearer $NOMS_KEY"
Set (or change) it with PUT /v1/market. Until you choose, foods requests return
403 /problems/market-not-selected; once set, foods is scoped to that market.
curl -X PUT https://api.noms.sh/v1/market \ -H "Authorization: Bearer $NOMS_KEY" \ -H "Content-Type: application/json" \ -d '{ "market": "CL" }'
Choosing a market that no foods are available in returns
422 /problems/unknown-market (it echoes available). Paid tiers serve all markets,
so setting one returns 409 /problems/market-not-applicable, and GET reports
chosen: null with a note explaining that your plan already covers everything. To
narrow a single request, add a market_countries filter to it instead. Like
GET /v1/usage, both calls are unmetered.
The free tier is search-first
Choosing a market is one of three limits the free Taster tier carries; the other two shape how you list. Rate limits & quotas covers all three in one place.
Next steps
- Common scenarios: copy-paste recipes that put these four levers together.