Free plan

Food Search API

Full-text search across 6.1 million food products by name, brand, or category — relevance-ranked, paginated, and fast. Search in any language the label was printed in (Cyrillic included), and pass lang=en|fr|es|de|it|bg for localized names, categories and allergens. One GET request returns clean JSON your app can render immediately.

200 OK
GET /v1/foods/search?q=banana
curl -H "X-API-Key: food_•••" \
  "https://foodbase.dev/v1/foods/search?q=banana&limit=20"

{
  "data": [
    {
      "id": "62ea12b8-b260-c288-5c38-3a4906f5d6dd",
      "name_default": "Banana",
      "brand": null,
      "barcode": "4011",
      "category": "Fruits"
    }
  ],
  "total": 1342,
  "limit": 20,
  "offset": 0
}

Prefer to click around? Try the product explorer or the photo playground.

What you get back

id

Stable food ID — use it with the other endpoints.

name_default

Product name in its primary language.

name_localized / category_localized

Translations in the requested lang (en, fr, es, de, it, bg) where available — original text otherwise.

brand

Brand / manufacturer when known.

barcode

EAN-13 / UPC barcode when available.

category

Top-level food category.

total / limit / offset

Pagination metadata for the full result set.

Included on the free plan — 100 requests/day, no credit card required. See plans →

Code samples

The same request in curl, JavaScript and Python. Replace YOUR_API_KEY with a key from your dashboard.


              curl "https://foodbase.dev/v1/foods/search?q=banana&limit=20" \
  -H "X-API-Key: YOUR_API_KEY"
            

How it works

What one search request does

A search is a single GET with the text in q. The words are matched against the product name and the brand together, so q=olympus milk finds products whose name never says Olympus, and the last word is treated as a prefix, so milk olym already matches while the user is still typing. All words must match; there is no OR. q must be 1 to 200 characters; a missing or empty q is rejected before any search runs, so guard the empty search box on your side.

Results are relevance-ranked, and when two products match equally well the more popular one is listed first. That is why a one-word query such as banana puts store-brand fruit and a baby-food puree at the top of the sample, and why adding a second word (a brand, a flavor) is the quickest way to move a specific product up.

Category names are a fallback, not part of the main match: only when the name-and-brand search finds nothing does the API look for products whose category contains the words, in whatever language the category is stored. That is what makes a query such as Seeds & Related Snacks or Fruits tropicaux return a list, ordered by popularity, instead of an empty array.

  • The order is neither alphabetical nor by id. Do not re-sort the data array on the client.
  • If the input is a digits-only GTIN, send it to the Barcode Lookup API instead: it resolves zero-padding variants (0000080042556 finds 80042556), which a text search will not.

Reading a result row

Each item in data is a FoodListItem, a lighter shape than the full record: id, barcode, source, name_default, brand, category, image_url, nutriscore and nova_group, plus name_localized (always present) and category_localized when a non-English lang is passed. There are no nutrients here; the list is built for rendering rows, not for calculations.

Several fields are nullable and the sample shows it. The Roshen 'Banana land' candy (4f995c13-c0df-bfcc-b6ec-449d312def75) has image_url null and nova_group null; brand and barcode can be null as well. nutriscore values outside a to e, like the 'n' and 'u' in the sample, mean no grade was computed, so render them as 'not rated' instead of the raw letter.

category is the full Open Food Facts category path, comma-separated from broad to specific ('...,Fruits tropicaux,Bananes' for the Bio Village bananas). Show the last segment as the label, or the first for a coarse grouping, and strip language prefixes such as pt: before display. image_url is a contributor photo on images.openfoodfacts.org, so ship a placeholder for the null case.

Pagination, total and stable ids

limit is 1 to 100 (default 20) and offset is 0 to 10000. total is a capped count: the API stops counting at 1,000 matches, so the sample's total of 1000 for q=banana with limit=3 means at least 1,000. Render it as '1,000+' and do not compute a last-page number from it.

Page by adding limit to offset until data comes back with fewer than limit items. Because offset tops out at 10000, a query with more matches than that needs a narrower q, not a deeper page.

  • Ids are stable across FoodBase re-imports, so store the id from a search row and resolve it later with the Food Database API without searching again.
  • Ranking is computed per request and popularity can change, so an offset is not a durable cursor. Persist ids, not page positions.

Searching in another language

lang defaults to en. Every language searches that language's entry in the product's name array where a volunteer or FoodBase's AI translation has provided one, and falls back to name_default, the name as printed on the label, for products that have no such entry; the brand is always part of the match. So lang=bg reaches every product, but prefers Bulgarian names where they exist, and a Cyrillic label is found under any lang.

The lang you pass also shapes the output. name_localized holds the translated name: the sample, requested with lang=en, turns 'Puré de plátano y fresa' into 'Banana and Strawberry Puree', and falls back to name_default when there is nothing to translate, as 'Banana land' shows. Display name_localized when non-null and name_default otherwise. category_localized is not a required field and is absent from the sample, so read it defensively.

Autocomplete and the hand-off to full records

Search is the right endpoint for a type-ahead box. Debounce keystrokes, request limit=8 to 10, and cancel the in-flight request when the user types again so a slow earlier response cannot overwrite a newer one. Every keystroke that reaches the API is one request against your daily quota, so debouncing is a cost control as much as a UX detail.

When the user picks a row, call the Food Database API with the id for the complete record, or the Nutrition API when you only need the per-100 g nutrient table. One search plus one detail request is cheaper than fetching full records for every suggestion.

The captured search response carries no Cache-Control header, unlike food records, which are cacheable for 300 seconds. Cache search results yourself, keyed by the exact q, lang, limit and offset; the same short prefixes recur constantly in a type-ahead box. X-RateLimit-Second-Remaining shows how much burst is left, and on a 429 the Retry-After header says how many seconds to wait.

Build with it

Food pickers & autocomplete

Power a search box in a calorie tracker or recipe app — type-ahead results from 6.1M products.

Catalog enrichment

Match your own SKUs to canonical food records by name or brand.

Discovery feeds

Browse by category or keyword to build curated food lists.

Limits by plan

Free 100 requests/day shared across the account's keys; 1 request/s per key, burst 5. The 25/day nutrient-breakdown cap does not apply to search.
Starter 5,000 requests/day; 5 requests/s per key, burst 25.
Pro 25,000 requests/day; 20 requests/s per key, burst 100.
Enterprise 100,000 requests/day; 50 requests/s per key, burst 250.

Every response carries X-RateLimit-* headers with the remaining budget; every 429 carries Retry-After. Compare plans →

Errors and how to handle them

401 — The X-API-Key header is missing, malformed or belongs to a revoked key.

Check the header name and the key in your dashboard. Do not retry until it is fixed.

422 — q is missing or empty, longer than 200 characters, limit is outside 1 to 100, offset is outside 0 to 10000, or lang is not one of en, fr, es, de, it, bg.

Read the details array in the Validation Error body to see which parameter failed and fix the request client-side. Never retry a 422 unchanged.

429 — The per-second burst for the key is exhausted or the account's daily quota is used up.

Wait the number of seconds in Retry-After and retry once. In an autocomplete flow, drop the stale keystroke instead of queueing it.

Compared to other APIs

A general-purpose search engine or an e-commerce catalog indexes text; FoodBase search indexes food products, so a match comes back with the barcode, category path, Nutri-Score and NOVA group attached and an id that resolves to a nutrient table in one more request. The trade-off is scope: it matches product names and brands, with category names only as a fallback, rather than every field of the record, and a non-English lang searches localized names where they exist and label names elsewhere, rather than translating your query. There is no client library to install and no index to build on your side, just one GET and one JSON object. If you are weighing food data providers side by side, /compare/food-database-apis lays out what each one covers.

See the full comparison of food database APIs →

Frequently asked questions

Why does total come back as exactly 1000? +

The count is capped. Counting stops at 1,000 matches, so 1000 means at least that many. Show it as '1,000+' and page by offset until data returns fewer than limit rows rather than dividing total by limit.

What does the API return when nothing matches? +

A 200 with an empty data array, not a 404. Treat the empty array as your no-results state. Because every word in q must match, dropping the least specific word or a typo-prone one is the usual recovery before you show it.

Can I search by brand or category alone? +

Brand, yes: the brand is part of what every word in q is matched against, so q=Hacendado lists that brand's products and adding a product word narrows the list. Category names are only tried when the name-and-brand search returns nothing, so q=Fruits tropicaux returns the products filed under that category, ordered by popularity rather than relevance.

Does a search result include nutrients? +

No. Search rows are the lighter FoodListItem shape without nutrient fields. Pass the row's id to the Nutrition API on selection rather than for every suggestion, because the Free plan allows 25 nutrient breakdowns per day.

Which lang should a Bulgarian app pass? +

lang=bg searches Bulgarian localized names and returns name_localized in Bulgarian where a translation exists. Labels entered in Cyrillic as the default name are found with lang=en, because en searches the default name regardless of script. Query bg first and fall back to en on an empty result.

Is the ranking the same as on the FoodBase website? +

Yes. The product explorer at /products and the category pages at /categories/ run on the same endpoint, so you can test a query there and expect the same order in your app for the same q and lang. Search uses no AI credits; each call is one request against the daily quota.

Start building with the Food Search API

Free to start. No credit card required.

Get API key Go to Dashboard