FDA recall data · implementation

Searching openFDA recalls by company: nine traps in the enforcement API

openFDA publishes FDA's food, drug and device recall enforcement reports through a free API. Searching them for one company looks like a single query and is not: how the name is written decides whether any recall is found, and the answer for “none” looks like an error. Three of the nine traps below return a clean-looking answer about a company that does have recalls — a wrong field name, a legal suffix spelled differently from the filing, and an apostrophe. Each was measured on , with the script and its output at the end.

Where the data lives

GET https://api.fda.gov/food/enforcement.json?search=recalling_firm:"Kraft Heinz Foods"&limit=100

The query syntax is documented at open.fda.gov. No key is needed for the queries below.

1. No match is HTTP 404

A firm name that appears in no report returned HTTP 404 with {"code":"NOT_FOUND","message":"No matches found!"}. Treat that body as "no records", not as a failure.

2. A misspelled field answers exactly the same way

Searching recalling_firmm instead of recalling_firm returned the identical HTTP 404 and body. openFDA cannot tell you that the field does not exist, so a typo in a field name reads as "this company has no recalls". Pin field names in code and test each query against a firm you know has recalls.

3. A wrong path is a different 404

A misspelled endpoint returned HTTP 404 with content-type text/html; charset=utf-8 and an HTML page. Accept a 404 as "no records" only when the body is JSON with error.code equal to NOT_FOUND; any other 404 is a broken request.

4. Unquoted words are not a phrase

recalling_firm:Kraft+Heinz returned 59 reports; the quoted phrase "Kraft Heinz" returned 34. Without quotes the words are not searched as one phrase, and the count grows. Quote multi-word names.

5. A legal suffix has to match how the firm filed

Food enforcement reports, searched as quoted phrases:

recalling_firm phraseHTTPtotal
Kraft Heinz Foods Company20019
Kraft Heinz Foods Co404no matches
Kraft Heinz Foods20028

The phrase is matched word by word, so a suffix written differently from the filing ("Co" for "Company") finds nothing, while the name without its suffix finds more. Search on the name's distinctive words, then compare the firms you get back with the one you meant.

6. An apostrophe changes the words

recalling_firm phraseHTTPtotal
Trader Joe's2003
Trader Joes2009
Trader Joe404no matches

The spellings with and without the apostrophe return different totals, and dropping the "s" finds nothing. Search both spellings.

7. An ampersand does not

In drug enforcement reports, "Johnson & Johnson" and "Johnson Johnson" both returned 24. The ampersand made no difference.

8. Limit and skip have ceilings

limit=1001 returned HTTP 400: Limit cannot exceed 1000 results for search requests. Use the skip or search_after param to get additional results. A skip above 25,000 returned HTTP 400: Skip value must 25000 or less. Compare meta.results.total with the rows you received before treating a list as complete.

9. Food, drug and device are three separate endpoints

A company's recalls can sit in any of them, and each is searched on its own: food held 29,406 reports; drug held 17,965 reports; device held 39,949 reports on the run date, each reporting meta.last_updated 2026-09-09. Query all three and keep each endpoint's answer separate until all three have succeeded.

The whole thing, runnable

Node 18 or later, no dependencies, no key. It pauses two seconds between calls.

// Measures how openFDA's recall enforcement endpoints answer a search by recalling firm. Node 18+, no dependencies, no key.
// Seventeen GETs, two seconds apart. Prints counts and firm names as openFDA publishes them.
const BASE = 'https://api.fda.gov';
const UA = 'openfda-firm-traps/1.0 (reads the public openFDA API)';
const pause = () => new Promise((r) => setTimeout(r, 2000));
console.log(`run at ${new Date().toISOString()}`);

async function get(path, params) {
  await pause();
  const qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v).replace(/%3A/g, ':')}`).join('&');
  const res = await fetch(`${BASE}${path}?${qs}`, { headers: { 'user-agent': UA } });
  const text = await res.text();
  let json = null;
  try { json = JSON.parse(text); } catch {}
  return { status: res.status, type: res.headers.get('content-type') ?? '', text, json };
}
const total = (r) => r.json?.meta?.results?.total ?? null;
const code = (r) => r.json?.error?.code ?? null;
const firm = (name) => ({ search: `recalling_firm:"${name}"`, limit: '1' });
const line = (label, r) => console.log(`${label} -> ${r.status}, total ${total(r)}, error ${code(r)}`);

// T1 no match is HTTP 404
const t1 = await get('/food/enforcement.json', firm('Zzqxv Nonexistent'));
console.log(`T1 no match -> ${t1.status}, error ${JSON.stringify(t1.json?.error)}`);

// T2 a misspelled field answers exactly like no match
const t2 = await get('/food/enforcement.json', { search: 'recalling_firmm:"Kraft Heinz"', limit: '1' });
console.log(`T2 misspelled field -> ${t2.status}, error ${JSON.stringify(t2.json?.error)}`);

// T3 a wrong path is a different 404
const t3 = await get('/food/enforcment.json', firm('Kraft Heinz'));
console.log(`T3 misspelled endpoint -> ${t3.status}, content-type ${JSON.stringify(t3.type)}, starts ${JSON.stringify(t3.text.trim().slice(0, 15))}`);

// T4 unquoted words are not a phrase
const t4a = await get('/food/enforcement.json', { search: 'recalling_firm:Kraft+Heinz', limit: '1' });
const t4b = await get('/food/enforcement.json', firm('Kraft Heinz'));
console.log(`T4 unquoted Kraft+Heinz -> total ${total(t4a)}; quoted "Kraft Heinz" -> total ${total(t4b)}`);

// T5 a legal suffix has to match the filing
for (const name of ['Kraft Heinz Foods Company', 'Kraft Heinz Foods Co', 'Kraft Heinz Foods']) line(`T5 food "${name}"`, await get('/food/enforcement.json', firm(name)));

// T6 an apostrophe changes the tokens
for (const name of ["Trader Joe's", 'Trader Joes', 'Trader Joe']) line(`T6 food "${name}"`, await get('/food/enforcement.json', firm(name)));

// T7 an ampersand does not
for (const name of ['Johnson & Johnson', 'Johnson Johnson']) line(`T7 drug "${name}"`, await get('/drug/enforcement.json', firm(name)));

// T8 limit and skip ceilings
const t8a = await get('/food/enforcement.json', { search: 'recalling_firm:"Kraft Heinz"', limit: '1001' });
const t8b = await get('/food/enforcement.json', { search: 'recalling_firm:"Kraft Heinz"', limit: '1', skip: '25001' });
console.log(`T8 limit=1001 -> ${t8a.status}, ${JSON.stringify(t8a.json?.error?.message)}; skip=25001 -> ${t8b.status}, ${JSON.stringify(t8b.json?.error?.message)}`);

// T9 the three endpoints are separate, and each dates itself
for (const ep of ['food', 'drug', 'device']) {
  const r = await get(`/${ep}/enforcement.json`, { limit: '1' });
  console.log(`T9 ${ep}/enforcement -> ${r.status}, total ${total(r)}, last_updated ${r.json?.meta?.last_updated}`);
}

Its output on :

$ node --version
v24.20.0
$ node openfda-firm.mjs
run at 2026-09-17T20:50:58.288Z
T1 no match -> 404, error {"code":"NOT_FOUND","message":"No matches found!"}
T2 misspelled field -> 404, error {"code":"NOT_FOUND","message":"No matches found!"}
T3 misspelled endpoint -> 404, content-type "text/html; charset=utf-8", starts "<!DOCTYPE html>"
T4 unquoted Kraft+Heinz -> total 59; quoted "Kraft Heinz" -> total 34
T5 food "Kraft Heinz Foods Company" -> 200, total 19, error null
T5 food "Kraft Heinz Foods Co" -> 404, total null, error NOT_FOUND
T5 food "Kraft Heinz Foods" -> 200, total 28, error null
T6 food "Trader Joe's" -> 200, total 3, error null
T6 food "Trader Joes" -> 200, total 9, error null
T6 food "Trader Joe" -> 404, total null, error NOT_FOUND
T7 drug "Johnson & Johnson" -> 200, total 24, error null
T7 drug "Johnson Johnson" -> 200, total 24, error null
T8 limit=1001 -> 400, "Limit cannot exceed 1000 results for search requests. Use the skip or search_after param to get additional results."; skip=25001 -> 400, "Skip value must 25000 or less."
T9 food/enforcement -> 200, total 29406, last_updated 2026-09-09
T9 drug/enforcement -> 200, total 17965, last_updated 2026-09-09
T9 device/enforcement -> 200, total 39949, last_updated 2026-09-09
exit 0

What this does not cover

What any recall means, FDA's recall classifications, and openFDA's other datasets. This page is about finding a company's enforcement reports correctly.

Aervik Labs' EPA Compliance Risk API reads these endpoints for the FDA section of its company screen.

Last updated . Corrections: see About.