Consumer product recall data · implementation
CPSC's Recall API: nine traps in saferproducts.gov's recall service
The U.S. Consumer Product Safety Commission publishes its recalls through a free REST service with no key. It is simple to call and easy to misread: the parameter most people reach for first finds a minority of recent recalls. Each trap below was measured on , with the script and its output at the end.
Where the data lives
GET https://www.saferproducts.gov/RestWebServices/Recall?format=json&Importer=Example%20Company
CPSC describes the service on its Recalls API information page. Search parameters include the recall title, product name, recall dates and the names of the firms involved.
1. The default response is XML
Without format=json the service answered with content-type application/xml; charset=utf-8 and a body
beginning <Recalls . Send format=json on every call.
2. JSON is a bare array, with no total and no paging
With format=json the body was an array of 26 recalls and nothing else. There is no count to compare
against, so the only check on completeness is the array itself.
3. Firm names match as case-insensitive substrings
Manufacturer=Fisher-Price and Manufacturer=fisher-price both returned 26 recalls;
Manufacturer=Fisher returned 32. A short name pulls in every firm that contains it, so score the names you
get back before counting a recall against a company.
4. A legal suffix in the name finds less, or nothing
Because matching is by substring, the whole name you send has to appear, character for character, inside CPSC's spelling of the firm.
Manufacturer=Fisher-Price returned 26 recalls, Fisher-Price Inc. 1 and
Fisher-Price, Inc. 0. Importer=Walmart returned 12 and Walmart Inc. 4.
Send the name without its legal suffix and punctuation, then score the firm names you get back.
5. The recalling firm is usually not listed as the Manufacturer
Each recall lists firms under Manufacturers, Importers, Distributors and Retailers. Of the 1159 recalls announced
from 2024-01-04 to 2026-09-10, 26% named a manufacturer, 55% an importer and 33% a distributor, and
73% named no manufacturer at all but an importer or distributor. For one well-known firm,
Manufacturer=Fisher-Price finds recalls ending 2010-09-30, while the 54 recalls with the name in their
title run to 2025-04-10: of the 13 since 2015, 0 list a manufacturer, 6 list the firm as importer and
7 as distributor.
To find a company's recalls, query Manufacturer, Importer and Distributor and merge
the results by RecallID. Retailers are listed too, but a retailer is not usually the firm conducting the recall.
6. A misspelled parameter is ignored, not refused
RecallDateStart=2020-01-01 narrowed the Fisher-Price manufacturer search to 0 rows. The same request with
the parameter misspelled, RecallDateStartt, returned HTTP 200 and all 26 rows, exactly as if no filter
had been sent. Check that a filter changed the result before trusting it.
7. No match is an empty array with HTTP 200
A name no recall contains returned HTTP 200 and the body []. That is a real "none", but
trap 6 means it is only as good as the parameter names you sent.
8. Broad searches are not cut short
Importer=Inc returned 1883 recalls in one response. There was no page limit to work around in these
measurements, which also means a careless substring can return a very large body.
9. /robots.txt is not a robots file
saferproducts.gov's /robots.txt answered HTTP 404 with text/html; charset=utf-8, an HTML error page.
With no crawl rules published, pace your calls; the script below waits four seconds between requests.
The whole thing, runnable
Node 18 or later, no dependencies, no key. It prints counts and dates only.
// Measures the traps in CPSC's Recall API (saferproducts.gov/RestWebServices/Recall). Node 18+, no dependencies, no key.
// Fifteen requests, four seconds apart, with a User-Agent that names the caller. Prints counts and dates, never product images or
// consumer contact details.
const BASE = 'https://www.saferproducts.gov/RestWebServices/Recall';
const UA = 'cpsc-recall-traps/1.0 (reads the public Recall API)';
const pause = () => new Promise((r) => setTimeout(r, 4000));
async function get(query) {
await pause();
const url = `${BASE}${query ? `?${query}` : ''}`;
const res = await fetch(url, { headers: { 'user-agent': UA } });
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
console.log(`${new Date().toISOString()} GET ?${query} -> ${res.status}, ${res.headers.get('content-type')}, ${text.length} bytes`);
return { status: res.status, type: res.headers.get('content-type') ?? '', text, json };
}
const dates = (list) => list.map((r) => String(r.RecallDate ?? '').slice(0, 10)).filter(Boolean).sort();
const named = (r, key) => (r[key] ?? []).some((x) => x && typeof x.Name === 'string' && x.Name.trim() !== '');
// T1 the default format is XML
const t1 = await get('Manufacturer=Fisher-Price');
console.log(`T1 no format -> content-type ${JSON.stringify(t1.type)}, starts ${JSON.stringify(t1.text.slice(0, 9))}`);
// T2 json is a bare array: no total, no paging
const t2 = await get('format=json&Manufacturer=Fisher-Price');
console.log(`T2 format=json -> array ${Array.isArray(t2.json)}, length ${t2.json?.length}, top-level keys ${JSON.stringify(Array.isArray(t2.json) ? [] : Object.keys(t2.json ?? {}))}`);
// T3 name parameters are case-insensitive substrings
const t3a = await get('format=json&Manufacturer=fisher-price');
const t3b = await get('format=json&Manufacturer=Fisher');
console.log(`T3 Manufacturer Fisher-Price ${t2.json.length}, fisher-price ${t3a.json.length}, Fisher ${t3b.json.length}`);
// T4 the recalling firm is usually not the Manufacturer
const t4a = dates(t2.json);
const t4b = await get('format=json&RecallTitle=Fisher-Price');
const titled = t4b.json;
const since2015 = titled.filter((r) => String(r.RecallDate).slice(0, 10) >= '2015-01-01');
console.log(`T4 Manufacturer=Fisher-Price latest ${t4a.at(-1)}; RecallTitle=Fisher-Price ${titled.length} recalls, latest ${dates(titled).at(-1)}; since 2015 ${since2015.length}, with Manufacturers filled ${since2015.filter((r) => named(r, 'Manufacturers')).length}, Importers ${since2015.filter((r) => named(r, 'Importers')).length}, Distributors ${since2015.filter((r) => named(r, 'Distributors')).length}`);
const recent = await get('format=json&RecallDateStart=2024-01-01');
const n = recent.json.length;
const pct = (k) => `${Math.round((100 * recent.json.filter((r) => named(r, k)).length) / n)}%`;
const noMfr = recent.json.filter((r) => !named(r, 'Manufacturers') && (named(r, 'Importers') || named(r, 'Distributors'))).length;
console.log(`T4 recalls since 2024-01-01: ${n} (${dates(recent.json)[0]} to ${dates(recent.json).at(-1)}); Manufacturers filled ${pct('Manufacturers')}, Importers ${pct('Importers')}, Distributors ${pct('Distributors')}, Retailers ${pct('Retailers')}; no manufacturer but an importer or distributor ${Math.round((100 * noMfr) / n)}%`);
// T5 a misspelled parameter is ignored, not refused
const t5a = await get('format=json&Manufacturer=Fisher-Price&RecallDateStart=2020-01-01');
const t5b = await get('format=json&Manufacturer=Fisher-Price&RecallDateStartt=2020-01-01');
console.log(`T5 RecallDateStart=2020-01-01 -> ${t5a.status}, ${t5a.json.length} rows; RecallDateStartt=2020-01-01 -> ${t5b.status}, ${t5b.json.length} rows`);
// T6 no match is an empty array with HTTP 200
const t6 = await get('format=json&Manufacturer=Zzqxvnonexistent');
console.log(`T6 no match -> ${t6.status}, body ${JSON.stringify(t6.text)}`);
// T7 large results are not capped
const t7 = await get('format=json&Importer=Inc');
console.log(`T7 Importer=Inc -> ${t7.json.length} rows`);
// T8 robots.txt is not a robots file
const t8 = await fetch('https://www.saferproducts.gov/robots.txt', { headers: { 'user-agent': UA } });
const t8text = await t8.text();
console.log(`T8 /robots.txt -> ${t8.status}, ${t8.headers.get('content-type')}, starts ${JSON.stringify(t8text.trim().slice(0, 15))}`);
// T9 a legal suffix in the query finds less, or nothing
const t9a = await get('format=json&Manufacturer=' + encodeURIComponent('Fisher-Price Inc.'));
const t9b = await get('format=json&Manufacturer=' + encodeURIComponent('Fisher-Price, Inc.'));
const t9c = await get('format=json&Importer=Walmart');
const t9d = await get('format=json&Importer=' + encodeURIComponent('Walmart Inc.'));
console.log(`T9 Manufacturer "Fisher-Price" ${t2.json.length}, "Fisher-Price Inc." ${t9a.json.length}, "Fisher-Price, Inc." ${t9b.json.length}; Importer "Walmart" ${t9c.json.length}, "Walmart Inc." ${t9d.json.length}`);
Its output on :
2026-09-15T10:49:29.868Z GET ?Manufacturer=Fisher-Price -> 200, application/xml; charset=utf-8, 92562 bytes T1 no format -> content-type "application/xml; charset=utf-8", starts "<Recalls " 2026-09-15T10:49:34.317Z GET ?format=json&Manufacturer=Fisher-Price -> 200, application/json; charset=utf-8, 85814 bytes T2 format=json -> array true, length 26, top-level keys [] 2026-09-15T10:49:38.832Z GET ?format=json&Manufacturer=fisher-price -> 200, application/json; charset=utf-8, 85814 bytes 2026-09-15T10:49:43.301Z GET ?format=json&Manufacturer=Fisher -> 200, application/json; charset=utf-8, 112404 bytes T3 Manufacturer Fisher-Price 26, fisher-price 26, Fisher 32 2026-09-15T10:49:47.699Z GET ?format=json&RecallTitle=Fisher-Price -> 200, application/json; charset=utf-8, 152377 bytes T4 Manufacturer=Fisher-Price latest 2010-09-30; RecallTitle=Fisher-Price 54 recalls, latest 2025-04-10; since 2015 13, with Manufacturers filled 0, Importers 6, Distributors 7 2026-09-15T10:49:53.094Z GET ?format=json&RecallDateStart=2024-01-01 -> 200, application/json; charset=utf-8, 3645469 bytes T4 recalls since 2024-01-01: 1159 (2024-01-04 to 2026-09-10); Manufacturers filled 26%, Importers 55%, Distributors 33%, Retailers 100%; no manufacturer but an importer or distributor 73% 2026-09-15T10:49:57.316Z GET ?format=json&Manufacturer=Fisher-Price&RecallDateStart=2020-01-01 -> 200, application/json; charset=utf-8, 2 bytes 2026-09-15T10:50:01.771Z GET ?format=json&Manufacturer=Fisher-Price&RecallDateStartt=2020-01-01 -> 200, application/json; charset=utf-8, 85814 bytes T5 RecallDateStart=2020-01-01 -> 200, 0 rows; RecallDateStartt=2020-01-01 -> 200, 26 rows 2026-09-15T10:50:06.160Z GET ?format=json&Manufacturer=Zzqxvnonexistent -> 200, application/json; charset=utf-8, 2 bytes T6 no match -> 200, body "[]" 2026-09-15T10:50:12.508Z GET ?format=json&Importer=Inc -> 200, application/json; charset=utf-8, 5150870 bytes T7 Importer=Inc -> 1883 rows T8 /robots.txt -> 404, text/html; charset=utf-8, starts "<!DOCTYPE html>" 2026-09-15T10:50:17.003Z GET ?format=json&Manufacturer=Fisher-Price%20Inc. -> 200, application/json; charset=utf-8, 4470 bytes 2026-09-15T10:50:21.396Z GET ?format=json&Manufacturer=Fisher-Price%2C%20Inc. -> 200, application/json; charset=utf-8, 2 bytes 2026-09-15T10:50:25.813Z GET ?format=json&Importer=Walmart -> 200, application/json; charset=utf-8, 32155 bytes 2026-09-15T10:50:30.182Z GET ?format=json&Importer=Walmart%20Inc. -> 200, application/json; charset=utf-8, 10562 bytes T9 Manufacturer "Fisher-Price" 26, "Fisher-Price Inc." 1, "Fisher-Price, Inc." 0; Importer "Walmart" 12, "Walmart Inc." 4 exit 0
What this does not cover
What any recall means for a product you own or sell, CPSC's other data services, and the field-by-field definitions on CPSC's own page. This page is about reading the Recall API correctly.
Aervik Labs' EPA Compliance Risk API reads this service for the recall section of its company screen.