FDA Recall API: A Working Guide to openFDA Enforcement
How to pull FDA drug, device and food recall data from the openFDA enforcement API: the 26,000-record skip wall, the silent OR bug, and stale status.
The openFDA enforcement API is free, keyless, and well documented on the surface. It is also full of failure modes that return HTTP 200 with quietly wrong data. Every number and error string below was measured against the live API on 2026-07-20; anything I could not reproduce has been cut.
Pick the right endpoint first
There are four recall-shaped endpoints and they are not interchangeable. Choosing wrong gives you a different universe of records with no warning.
| Endpoint | Records | What it is |
|---|---|---|
drug/enforcement.json | 17,793 | Recall Enterprise System (RES) drug recalls |
device/enforcement.json | 39,519 | RES device recalls |
food/enforcement.json | 29,224 | RES food recalls |
device/recall.json | 58,756 | CDRH device recall database, a different schema entirely |
The three enforcement endpoints share their schema. device/recall.json does not: its fields include cfres_id, product_res_number, k_numbers, root_cause_description, event_date_posted, event_date_terminated and recall_status, and it has no classification field at all (count=classification.exact returns HTTP 404 "Nothing to count"). If you are filtering for Class I, you want an enforcement endpoint.
The two families also refresh on different clocks. On 2026-07-20 the three enforcement endpoints reported meta.last_updated of 2026-07-08, while device/recall.json reported 2026-07-17.
The OR bug that silently returns the wrong answer
This is the single most expensive trap, and it is undocumented. An unparenthesized OR discards every clause except the last one. All figures below are from food/enforcement.json.
search=classification:"Class I" OR state:"CA"returns 4,003 - exactly the count forstate:"CA"alone.- Reverse the operands and you get 12,809 - exactly
classification:"Class I"alone. - Wrap it:
search=(classification:"Class+I"+OR+state:"CA")returns 14,822 in both orders. That is the real union (12,809 + 4,003 - 1,990 overlap, and the AND of the two clauses does return 1,990).
No error is raised in any case. The nastier variant: if the final clause happens to match nothing, the whole query 404s. A three-field keyword search - product_description:"listeria"+OR+reason_for_recall:"listeria"+OR+recalling_firm:"listeria" - returns 404 unparenthesized and 7,469 results parenthesized.
Rule: always wrap OR groups in parentheses. No exceptions.
Encoding: the plus sign is a space
In openFDA query strings, + means a space. Running your search through encodeURIComponent() percent-encodes it to %2B, which turns it into a literal plus and breaks range queries. You get HTTP 500 whose error.message is only the generic "Check your request and try again"; the useful part is buried in error.details:
[parse_exception] parse_exception: Encountered " "]" "] "" at line 1, column 33.
Was expecting:
"TO" ...
Always read error.details, not just error.message. All three of these are equivalent and return the same 759 records:
search=report_date:[20260101+TO+20260701]
search=report_date:%5B20260101+TO+20260701%5D
search=report_date:%5B20260101%20TO%2020260701%5D
Square brackets may be raw or encoded, your choice, and %20 works as well as +. Quotes are safest encoded as %22. The one hard invariant is that the separators must resolve to spaces, so never percent-encode the + itself. Build the search string by hand rather than handing the whole thing to a generic encoder.
Getting past 26,000 records
limit caps at 1,000 (limit=1001 returns HTTP 400 "Limit cannot exceed 1000 results for search requests. Use the skip or search_after param to get additional results."). skip caps at 25,000 (skip=25001 returns HTTP 400 "Skip value must 25000 or less.", sic). So a skip-based pager tops out at 26,000 records - which is below both device/enforcement (39,519) and food/enforcement (29,224). A naive skip loop silently drops about 34 percent of device recalls and reports success. Only drug/enforcement fits under the ceiling today, and it has 17,793 records and rising.
The fix is search_after, exposed through the Link response header on every 200:
Link: <https://api.fda.gov/food/enforcement.json?limit=1000&sort=report_date%3Adesc&skip=0&search_after=0%3D1761696000000%3B1%3D62bfac5e...>; rel="next"
Follow that URL verbatim until the header disappears; its absence is the end-of-data signal, confirmed on a filtered query whose final page carried no Link. Note that FDA builds skip=0 into the cursor URL itself, so do not strip it and do not substitute your own nonzero skip. Setting an explicit sort is worth doing for stable ordering, though the header is emitted with or without one.
const BASE = 'https://api.fda.gov/device/enforcement.json';
async function fetchAll() {
let url = `${BASE}?limit=1000&sort=report_date:desc`;
const out = [];
while (url) {
const res = await fetch(url);
// Zero matches is 404, not an empty array. Do NOT treat it as an outage.
if (res.status === 404) break;
if (!res.ok) throw new Error(`openFDA ${res.status}: ${await res.text()}`);
const body = await res.json();
out.push(...body.results);
const link = res.headers.get('link');
const next = link && link.match(/<([^>]+)>;\s*rel="next"/);
url = next ? next[1] : null;
}
return out;
}
fetchAll().then((r) => console.log(r.length)); // 39519
Run as written, that retrieves the complete device set - 39,519 records, all with distinct recall_number values - in exactly 40 requests. It composes with filters too: search=classification:"Class+I" on food pulls exactly 12,809 records in 13 requests, matching meta.results.total.
Zero results are a 404
An empty result set returns HTTP 404 with {"error":{"code":"NOT_FOUND","message":"No matches found!"}}. Any client doing if (!res.ok) throw will page a human at 3am because a quiet recall week looks like an outage. Map 404 to an empty array before anything else in your error handling.
What the data actually contains
The endpoint documentation lists coverage as 2004 to present. The published records do not go back that far. On all three enforcement endpoints, sort=report_date:asc returns 20120620, and search=report_date:[20040101+TO+20120619] returns 404 on every one. The real floor for report_date is 2012-06-20. recall_initiation_date does go earlier, because it records when the firm acted rather than when FDA published: 41 drug, 415 device and 116 food records carry an initiation date before 2012.
Field population is thinner than the field dictionary implies. Measured over the newest 1,000 records per endpoint:
- openfda object: empty in 1,000/1,000 device and 1,000/1,000 food records, and 439/1,000 drug records. The key is always present, so you get
{}rather thanundefined. Any brand-name or generic-name extraction is effectively drug-only and about 56 percent populated. - termination_date: absent from 965/1,000 drug, 520/1,000 food and all 1,000 device records. This is a recency artifact, not a schema difference - across the full datasets the field exists on 14,801 drug, 25,436 device and 27,724 food records. Recent recalls simply have not been terminated yet.
- more_code_info: present in the schema on all three endpoints but usually an empty string. Populated in 43/1,000 newest device records and 0/1,000 for both drug and food.
Do not trust the status field
FDA's own endpoint documentation states that the data should not be used to collect data to issue alerts to the public or to track the lifecycle of a recall, and that FDA does not update a recall's status after it has been classified. The data agrees: food recalls F-1717-2013 (published 2013-07-24) and F-0003-2014 (published 2013-10-23) are still marked "Ongoing" in 2026, and F-2287-2017 is marked "Ongoing" while carrying a populated termination_date of 20170803. Present it as "status at publication", never as live state. For scale, food status today splits 27,725 Terminated / 1,070 Ongoing / 429 Completed.
Classification has a fourth value
Everyone builds a Class I / II / III enum. There is exactly one record on each of the three enforcement endpoints classified "Not Yet Classified", and a three-value enum makes it unreachable. Also, always quote your values: classification:"Class+I" returns 12,809 on food, while unquoted classification:Class+I returns 12,877 - 68 phantom hits from token matching. Search is case-insensitive ("class+i" returns the same 12,809), so normalizing case is unnecessary.
The state filter is US-only in practice. Non-US firms carry full province names ("British Columbia", "Nova Scotia", "Ontario"), the literal string "N/A" (184 food records), or an empty string (211 food records). Exactly 415 food records have a state that is not a two-letter code, and exactly 415 food records have a country other than "United States" - so a two-letter state filter silently excludes every non-US food recall.
Counts and time series
Text fields need the .exact suffix for aggregation. count=recalling_firm returns HTTP 500 with error.details of "Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead."; count=recalling_firm.exact works. Date fields need no suffix - count=report_date gives you the full time series in one request (732 distinct dates on food, the earliest being {"time":"20120620","count":50}), which is by far the cheapest way to chart volume. Note the key is time, not term.
curl "https://api.fda.gov/food/enforcement.json?count=classification.exact"
# Class II 14674, Class I 12809, Class III 1740, Not Yet Classified 1
Freshness, and how to build a correct incremental job
Publication is weekly, on Wednesdays. Across the newest 1,000 food records there are 37 distinct report_date values, every one of them a Wednesday, spaced exactly seven days apart with no gaps - but the batch sizes swing from 3 to 157, so a nearly empty week is normal and not a bug. As of 2026-07-20, meta.last_updated on all three enforcement endpoints is 2026-07-08, twelve days stale. Surface that value rather than implying real-time data.
The lag from initiation to publication is substantial. Over those same newest 1,000 food records: minimum 9 days, median 35, p75 53, p90 103, maximum 559. One in ten takes over three months.
For an incremental pull: cursor on report_date (the publication field), not recall_initiation_date, and re-query a trailing window rather than a single day. Dedupe on recall_number, which was unique across all 1,000 newest records on each endpoint - but guard against bad values. In the newest 1,000, drug and food each contain one record with an empty recall_number and one whose value is the literal string "N/A"; device contained neither. Prefixes are D- for drug, Z- for device, and either F- (27,457 records) or H- (1,765 records) for food, so do not assume one prefix per product type.
Parse dates defensively
Dates are undelimited YYYYMMDD strings, and some are garbage. Device record Z-0139-2014 has a recall_initiation_date of 19301211; food record F-0880-2013 has 02121207, year 212. Both were still live at the time of writing.
function parseFdaDate(s) {
if (!/^\d{8}$/.test(s || '')) return null;
const y = +s.slice(0, 4);
if (y < 1990 || y > new Date().getFullYear() + 1) return null;
const d = new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T00:00:00Z`);
return isNaN(d) ? null : d;
}
That returns a Date for 20260708 and null for 19301211, 02121207, "N/A", "", null and impossible calendar dates such as 20261332.
API keys, rate limits, and the bulk download
No key is required. The documented keyless ceiling is 240 requests per minute and 1,000 per day per IP address; a free key keeps 240 per minute but raises the daily ceiling to 120,000 per key. A complete search_after backfill of all three enforcement endpoints is 88 requests at limit=1000 (18 + 40 + 30), which fits comfortably in the keyless budget. A skip-based re-pull on a schedule does not, and would be incomplete anyway.
FDA also publishes the complete datasets as downloadable JSON, with a per-endpoint page at open.fda.gov/apis/food/enforcement/download/ and the drug and device equivalents, plus a machine-readable index at api.fda.gov/download.json. For a one-time full backfill that beats 40 paginated calls. For incremental polling with filters the API wins, since the download is a full replacement each time.
If you would rather not maintain this
All of the above is a few hundred lines of code plus ongoing attention to schema drift. If that is not where you want your time to go, the US FDA Recalls Scraper on Apify wraps the three enforcement endpoints behind one input schema, with parenthesized OR keyword groups, 404-as-empty handling, and a normalized flat output shared across product types. To be straight about its current limits: it pages with skip rather than search_after, so it inherits the 26,000-record ceiling described above, and it reformats dates to ISO without rejecting the out-of-range ones. It is a convenience layer over the same free public API. If you enjoy owning your own pipeline, the code above is genuinely all you need, and it will go further.