Skip to content

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

ResourceEndpointWhat it is
FoodsGET /v1/foodsThe core resource: foods and ingredients
Food by idGET /v1/foods/{id}A single food by its 12-char id
NutrientsGET /v1/nutrientsThe nutrient catalog (e.g. PROTEIN)
BrandsGET /v1/brandsBrand catalog
Food groupsGET /v1/food-groupsFood-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 }
}
KeyWhat it holds
dataThe rows. Always an array on a list endpoint, even for one result or none
links.selfThe request you just made, echoed back
links.nextThe URL for the next page, or null when you are on the last one
meta.has_moretrue while pages remain, the same signal as links.next being set
meta.page_sizeHow 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).

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:

GET
curl -G https://api.noms.sh/v1/foods \
  --data-urlencode "q=amonds" \
  -H "Authorization: Bearer $NOMS_KEY"
Response
200 OK
{
"data": [
{
"id": "TIgbNPnzCIjX",
"barcode": "00041570110645",
"name": "Roasted salted almonds",matched despite the misspelled 'amonds'
"description": null,
"scientific_name": null,
"country_of_origin": null,
"ingredients_text": "Almonds, sunflower oil, sea salt.",
"is_foundational": false,
"basis_unit": "grams",
"brand_id": "XoluRyOx9o3C",
"food_group_id": "NUTS_AND_SEEDS"
},
{
"id": "g8E3YqCcPnq6",
"barcode": "00000026359434",
"name": "Maple bourbon almonds",
"description": null,
"scientific_name": null,
"country_of_origin": null,
"ingredients_text": null,
"is_foundational": false,
"basis_unit": "grams",
"brand_id": null,null where a value is unknown, never a dropped key
"food_group_id": null
}
],
"links": {
"self": "/v1/foods?q=amonds",
"next": null
},
"meta": {
"has_more": false,
"page_size": 10
}
}

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:

GET/v1/foods?barcode[eq]=00041570110645

A bare field=value means eq, so this is the same request:

GET/v1/foods?barcode=00041570110645

Repeat the pattern to combine filters. They all have to hold at once:

GET/v1/foods?food_group_id=DAIRY&is_foundational=false

What each endpoint filters by

EndpointFieldsOperators
/v1/foodsid, barcode, name, brand_id, brand_name, food_group_id, country_of_origin, market_countrieseq, in
/v1/foodsis_foundationaleq
/v1/foodsnutrient[CODE]eq, gt, gte, lt, lte
/v1/brandsid, nameeq, in
/v1/food-groupsid, nameeq, in
/v1/nutrientsid, name, uniteq, 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:

GET/v1/foods?nutrient[PROTEIN][gte]=20&nutrient[TOTAL_SUGARS][lt]=5

Sort

Sort with ?sort=. A bare field name sorts ascending; prefix it with - for descending:

GET/v1/foods?sort=-name

What each endpoint sorts by

EndpointSort keysDefault order
/v1/foodsid, name, brand_name, nutrient[CODE]id
/v1/brandsid, nameid
/v1/food-groupsid, nameid
/v1/nutrientsid, name, unitname

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:

GET/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:

GET/v1/foods/TIgbNPnzCIjX?include=brand,nutrients,serving_sizes

A 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:

KeyTrimsColumns you can name
fields[foods]The food itselfid, 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]brandid, name
fields[food-groups]food_groupid, name, icon_url
fields[nutrients]nutrientsid, name, unit, value
fields[serving_sizes]serving_sizesunit, quantity, grams, milliliters, descriptor, is_default
fields[images]imagestype, url
fields[market_countries]market_countriescountry_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:

GET
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"
Response
200 OK
{
"data": {
"id": "TIgbNPnzCIjX",
"name": "Roasted salted almonds",
"basis_unit": "grams",
"brand": {kept by fields[foods], trimmed to name by fields[brands]
"name": "Blue Diamond"
}
}
}

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.

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:

GET
curl https://api.noms.sh/v1/market \
  -H "Authorization: Bearer $NOMS_KEY"
Response
200 OK
{
"data": {
"chosen": null,your market, or null until you pick one
"available": [the markets foods are available in
"AR",
"CL",
"MX",
"US"
],
"note": nullwhy chosen is null on a paid plan; null on the free tier
}
}

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.

PUT
curl -X PUT https://api.noms.sh/v1/market \
  -H "Authorization: Bearer $NOMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "market": "CL"
  }'
Response
200 OK
{
"data": {
"chosen": "CL",
"available": [
"AR",
"CL",
"MX",
"US"
],
"note": null
}
}

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.

Next steps