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.

Last updated . Corrections: see About.