CPSC Recall API: No Pagination and a Broken Hazard Filter
CPSC's SaferProducts recall API has no pagination and three documented filters that always return zero. Verified parameter reference and workarounds.
The example in the official docs returns zero results
CPSC's Recalls Retrieval Web Services Programmers Guide, version 1.4 dated September 17, 2018, is the current vendor spec. Its worked example is this query: ?RecallTitle=stroller&Hazard=pinch. Run it today and you get an empty array. Drop the Hazard parameter and the same query returns 114 records.
That is the shape of every problem with this API. Nothing errors. Everything returns HTTP 200. You get a plausible-looking result that is quietly wrong.
Every figure below was measured against the live endpoint on 2026-07-20, against a full corpus of 9,905 records. Counts will drift as CPSC publishes; the behaviors will not, unless CPSC ships a fix.
The endpoint
Base URL: https://www.saferproducts.gov/RestWebServices/Recall
- No API key, no auth, no registration. None of the requests behind this article sent a credential.
- Default response format is XML. You must pass
format=jsonto get JSON. The v1.4 guide states this, and the live response confirms it: withoutformat,Content-Typecomes back asapplication/xml. - The response carries
Access-Control-Allow-Origin: *, so it is callable directly from browser JavaScript. - Twelve rapid sequential requests, no delay between them, all returned HTTP 200. The first took 0.29s cold and the remaining eleven took 0.01-0.04s each. No rate limit was observed, and the v1.4 guide documents none. That is an absence of evidence rather than a guarantee, so a courtesy delay is still the polite thing to do.
- The JSON response is a bare array. No envelope, no
total, nonextcursor.
Pagination does not exist, and you do not need it
The v1.4 parameter list contains no pagination parameter of any kind, and the obvious guesses are silently ignored. limit=10, rows=10, page=2, and offset=100 each returned the full 420 records for the calendar-2025 window, unchanged.
The fix is to stop trying. An unfiltered pull returns the entire 1973-2026 history in well under a second: 27,331,395 bytes raw, 9,590,456 bytes on the wire with gzip, measured at 0.44s.
curl --compressed \
'https://www.saferproducts.gov/RestWebServices/Recall?format=json' \
-o recalls.json
# 9,905 records, 9,590,456 bytes compressed, ~0.44s
Pull everything once, filter locally, and fall back to date-windowing only if you are memory constrained. Records come back sorted newest-first by RecallDate on both unfiltered and filtered queries, and RecallDate is non-null in all 9,905 records.
Why your Hazard filter returns nothing
Hazard is broken server-side. Hazard=fire, Hazard=Choking, Hazard=Laceration, and the guide's own Hazard=pinch all return [].
This is not a data-coverage problem. Every hazard object carries HazardType and HazardTypeID keys, and both are empty strings in all 9,786 hazard objects in the corpus. Hazards[].Name, by contrast, is populated in all 9,786. The observable behavior is what you would expect if the filter matched against the empty columns and never read Name, though only CPSC can confirm the server-side cause. The decisive test: 380 records have a hazard whose Name is exactly the string Choking, and Hazard=Choking still returns zero.
Two more documented filters behave the same way. UPC returns 0 even for a UPC copied straight out of a corpus record, and ManufacturerCountry=China returns 0 despite ManufacturerCountries[].Country being populated on all 8,739 such objects. All three are listed as searchable fields in v1.4. Nothing documents that they do not work.
Three ways a query fails, all with HTTP 200
- It works. You get a filtered subset.
- Recognized but broken. You get
[]and conclude "no matches" when the filter is simply dead. AffectsHazard,UPC,ManufacturerCountry. - Unrecognized. The parameter is dropped and you get all 9,905 records, so you conclude "everything matched". Verified for
BogusParam, and more dangerously for the intuitive-but-wrong names:Title,Description,Model,RecallCategory,Country,HazardName, andHazardseach returned the complete corpus.
To CPSC's credit, the v1.4 guide does warn about that third case, noting that made-up parameters will retrieve every recall record. It is still the trap people fall into, because the wrong names are the guessable ones. It is RecallTitle, not Title. It is RecallDescription, not Description. RecallTitle=crib returns 191 and RecallDescription=crib returns 279; Title=crib returns all 9,905.
The defense is a two-line assertion in your client: any filtered query whose result count equals the unfiltered corpus count is a bug, not a match.
const BASE = 'https://www.saferproducts.gov/RestWebServices/Recall?format=json';
const recalls = await (await fetch(BASE)).json(); // bare array, no wrapper
const TOTAL = recalls.length;
async function safeQuery(params) {
const url = BASE + '&' + new URLSearchParams(params).toString();
const rows = await (await fetch(url)).json();
if (rows.length === TOTAL) throw new Error('Param ignored: ' + JSON.stringify(params));
if (rows.length === 0) console.warn('Zero rows - verify the filter is not dead');
return rows;
}
// Hazard filtering has to happen client-side.
const choking = recalls.filter((r) =>
(r.Hazards || []).some((h) => /choking/i.test(h.Name || '')));
console.log(choking.length); // 1067 substring matches; 380 are exactly "Choking".
// Server-side Hazard=Choking returns 0.
Save that as an .mjs file and run it with Node 18 or later; it relies on top-level await and the built-in fetch.
Parameters that actually work
Every row below was executed against the live endpoint on 2026-07-20.
| Parameter | Status | Verified behavior |
|---|---|---|
| RecallID, RecallNumber | Works | Both unique across all 9,905 records with zero nulls. Either is a safe dedupe key. |
| RecallDateStart, RecallDateEnd | Works | Both bounds inclusive: start and end set to the same day returned that day's 15 records. Omitting End runs to today. |
| LastPublishDateStart, LastPublishDateEnd | Works | The correct primitive for incremental polling. See below. |
| RecallTitle, RecallDescription, ProductName | Works | Case-insensitive substring match. ProductName=crib returns 185. |
| Manufacturer, Retailer, Importer, Distributor | Works | Substring match on the company-name string. Importer=LLC returns 622. |
| Injury, Remedy, RemedyOption | Works | Injury=laceration returns 301; Remedy=refund returns 3,412. |
| ConsumerContact, RecallURL, ImageURL, InconjunctionURL | Works | Substring match on the respective field. Broad matches are slow: ImageURL=cpsc took 14.9s for 8,160 rows. |
| format | Works | format=json; defaults to XML. |
| Hazard | Broken | Returned 0 for every value tried, including values present verbatim in the data. |
| UPC | Broken | Returned 0 even for a UPC taken from a corpus record. |
| ManufacturerCountry | Broken | Returned 0. |
| limit, rows, page, offset | Ignored | Undocumented. Returns the full result set regardless. |
Parameter names are case-insensitive: recalldatestart and recalldateend returned the same 420 records as the canonical casing. Date values accept YYYY-MM-DD, YYYYMMDD, YYYY/MM/DD, and MM/DD/YYYY interchangeably; all four returned the identical 420 records for calendar 2025.
Incremental monitoring: poll LastPublishDate, not RecallDate
The obvious approach, a rolling RecallDateStart, silently loses data. LastPublishDate is strictly later than RecallDate in 6,769 of 9,905 records (68.3%), which is consistent with CPSC revising recall notices after announcement. A RecallDate window never sees those amendments.
curl --compressed 'https://www.saferproducts.gov/RestWebServices/Recall?format=json&LastPublishDateStart=2026-07-01&LastPublishDateEnd=2026-07-10'
# 34 records. LastPublishDateStart=2026-07-01 with no end bound returned 54.
Also watch RecallNumber suffixes. 9,794 are plain five digits, but 111 carry a trailing letter: 50 ending in "a", 50 in "b", 8 in "c", 3 in "d". A naive integer parse drops them.
If you do chunk by year, it is lossless in this corpus: per-year queries for 2021 through 2025 returned 219, 292, 324, 305, and 420 for a total of 1,560, exactly matching the per-year tallies computed locally from one unfiltered pull. That is a check you can rerun in about ten seconds, and you should, before trusting it in production.
Fields that are always empty
Do not build a feature on any of these. All were 100% empty across the corpus on 2026-07-20:
SoldAtLabel- null in 9,905 of 9,905 records; the field has exactly one distinct value, and it is null.Product.ModelandProduct.Description- empty in all 11,855 product objects.CompanyIDon every company collection:Manufacturers(8,073 objects),Retailers(8,157),Importers(4,169),Distributors(2,189). Zero populated.Hazards[].HazardTypeandHazards[].HazardTypeID- empty in all 9,786 hazard objects.
Product.Type and Product.CategoryID are partially populated: 7,049 and 7,044 of 11,855 product objects respectively, about 59% each.
UPC matching is mostly a fantasy. Only 454 of 9,905 recalls (4.6%) carry any UPC at all, 1,455 UPC values in total. If you are planning inventory reconciliation by UPC join, your ceiling is under 5% coverage, and the server-side filter is broken on top of that.
Data traps in the fields that are populated
NumberOfUnits is free text. Of 8,166 non-empty values, only 406 match /^[0-9,]+$/ and 7,760 contain at least one letter. Real values include "About 447" and "About 7,200 (In addition, about 370 were sold in Canada)". Summing this column raw is meaningless. Take the first /[\d,]+/ match, strip commas, and keep the original string alongside the parsed number.
Injuries has sentinel values. 1,734 recalls (17.5%) have an empty Injuries array, and the populated ones are dominated by negatives across at least eight spellings: "None reported" (1,393), "None reported." (1,200), "No incidents or injuries have been reported." (91), "None." (61), "None" (44), "No injuries have been reported." (35), "No injuries or incidents have been reported." (21), and "None Reported" (16). Testing Injuries.length > 0 counts no-injury recalls as injury recalls.
Retailers[].Name is a prose sentence, not a retailer. A representative value, verbatim from the corpus: "Home health care stores, drug stores and medical equipment stores nationwide and in home and health care catalogs from January 1994 through December 2007 for about $100." Across 8,157 retailer objects there are 8,089 distinct name strings, 4,156 of them longer than 120 characters and the longest 1,454. Grouping by this field raw gives you roughly one bucket per recall. You have to regex out the store names, the "from X through Y" sold-date range, and the "for about $N" or "for between $A and $B" price range.
Hazards[].Name is not a taxonomy either. 7,559 distinct values across 9,786 objects, and only 1,622 objects (16.6%) have a name of 40 characters or fewer. The rest is prose, up to 886 characters. Client-side hazard matching must be substring or regex, never equality, which is why the 380 exact-match "Choking" records are a small fraction of the 1,067 that match /choking/i.
The URL year lies. RecallDate disagrees with the year embedded in the record's own URL for 2,301 of 9,905 records (23.2%); three records have no year in the URL at all. Recall number 26156 has RecallDate 2025-12-18 and a /Recalls/2026/ URL. Deriving a year from the slug, or joining API records to scraped cpsc.gov pages by year, gives silently wrong buckets.
Normalize the text before you index it. The encoding is not clean. 1,041 records contain a curly quote or a typographic dash, 2,278 contain at least one non-ASCII character, and 3 contain a literal HTML entity such as &. Fold typographic punctuation to ASCII and decode stray entities before you build a search index or write a CSV.
RecallDelimited: the shortcut to CSV
https://www.saferproducts.gov/RestWebServices/RecallDelimited is documented in v1.4 under "Additional Retrieval Service" and is easy to miss. It accepts the same parameters as /Recall - verified for RecallDateStart, RecallDateEnd, RecallTitle, and RecallNumber, with the same broken Hazard and the same ignored limit - and returns every nested collection pre-flattened into pipe-separated, quote-delimited strings with doubled-quote escaping. A product actually named Pyro Diablo "Diablo Rising" 9 Shots comes back as "Pyro Diablo ""Diablo Rising"" 9 Shots".
Its key set differs from /Recall: RecallTitle, RecallURL, and RecallDescription replace Title, URL, and Description, and you get flattened ProductNames, ImageURLs, ImageCaptions, UPCs, HazardTypeIDs, and ManufacturerCompanyIDs columns, 33 keys in all. The emptiness caveats above apply unchanged to the ID columns.
Freshness and volume planning
As of 2026-07-20 the corpus spans RecallDate 1973-06-08 to 2026-07-16, with a maximum LastPublishDate of 2026-07-17. That is a single observation, so treat it as a lower bound on freshness rather than a service level.
Volume averaged 292.6 recalls per year over the ten full years 2016 through 2025, but the recent trend is up: 2025 closed at 420, and 2026 was already at 334 by July 20. Plan for growth. The v1.4 guide's own figure, "a count exceeding 8,000, as of September, 2018", is now roughly 1,900 records stale.
If you would rather not maintain this
Everything above is a couple of hundred lines of client code plus a standing obligation to notice when CPSC fixes or breaks something. If that is not where you want your time to go, our CPSC product recalls scraper on Apify wraps this endpoint with some of the workarounds already applied: year-sized date windows instead of pagination, client-side keyword matching across title, description, hazards, products, manufacturers and retailers, exact RecallNumber lookup, and the nested collections flattened to strings so the output exports cleanly to CSV. It does not do everything in this article; incremental LastPublishDate polling and unit-count parsing are on you either way. It is a convenience layer over a free public API, not a data source you cannot reach yourself.
Either way, the two rules that matter: assert that your filtered count never equals your corpus count, and never trust a zero.