# Product Recall to Catalog Matcher (CPSC, unofficial) (`plusonets/cpsc-recall-catalog-matcher`) Actor

Match a merchant catalog to official U.S. CPSC recall records. Exact UPCs and published model numbers are confirmed first. Brand and model similarities are only proposed for human review. No candidate found is not a safety clearance.

- **URL**: https://apify.com/plusonets/cpsc-recall-catalog-matcher.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** E-commerce, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $200.00 / 1,000 candidate matches

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Product Recall to Catalog Matcher (CPSC, unofficial)

Unofficial. Not affiliated with the U.S. Consumer Product Safety Commission or Health Canada.

A specialty retailer, resale shop, or ecommerce team pastes a catalog. The Actor reads official CPSC recall records for a date window, then builds a review queue. An exact UPC or a published model number can be a confirmed identifier match. A brand-and-model similarity is only a plausible candidate. A product that cannot be judged stays unassessable. A row that says **no candidate found is not a safety clearance**.

### Who it is for

Use it when a newly published recall might overlap products you already sell, list, or accept on trade-in. The output is a human review queue, not a compliance certificate. Every candidate explains which identifiers matched, which facts are missing, and which official recall page to open.

### What the default input returns

The default input is an empty object. It uses the built-in sample catalog and the last 120 days of CPSC recalls (or recalls published since the saved watermark). The sample copies brand, model, and UPC values from public CPSC records so the demo queue is not empty. It is not a claim that any merchant stocked those products.

On a first run of that sample, expect one row per item:

| Catalog item | What it demonstrates | Status |
| --- | --- | --- |
| `sample-hampton-halwin` | UPC `840059614922` and model `AK396H-MBK` on recall 26702 | Confirmed identifier match |
| `sample-scotch-tl1302z` | Model `TL1302Z` on recall 27024 | Confirmed identifier match |
| `sample-agrish-ag12011` | Model `AG-12011` on recall 27014 | Confirmed identifier match |
| `sample-love-to-dream-dated` | Model `LTD-SM23` plus date code `7W15TN` on recall 26798 | Confirmed identifier match |
| `sample-love-to-dream-undated` | Same model, date code not recorded | Plausible candidate |
| `sample-brookstone-prefix` | `BS-FIREPIT` is only a prefix of `BSFIREPIT01` on recall 26687 | Plausible candidate |
| `sample-victgoal-outside-date` | Model `HT-006` matches recall 26749, but the catalog date and lot are outside the published population | No candidate found, with an exclusion note |
| `sample-lithonia-brand-only` | Brand hits recall 27041, but the catalog has no arm code or manufacture date | Unassessable |
| `sample-northwind-paper` | Invented UPC and model, no recall hit | No candidate found |
| `sample-title-only` | Title only, no brand, model, or UPC | Unassessable, and not billed |

Nine of the ten items are checked. The title-only row is not billed, because there is nothing to check. The first run proposes six candidate packages (four confirmed, two plausible). Sample mode also asks the CPSC API for recall numbers 26702, 27024, 27014, 26798, 26687, 26749, and 27041 if that window did not already include them, so the demo still returns matches after the lookback moves.

A local default run on 2026-10-23 retrieved 199 CPSC recalls for 2026-06-25 through 2026-10-23, coverage `complete`, and wrote those ten rows. It charged 9 `catalog-item-checked` events and 6 `candidate-match` events. At the prices below that is $1.209 before the platform start event.

`safetyClearance` is always `false`. `notASafetyClearance` is always `true`.

### Input

| Field | Default | Meaning |
| --- | --- | --- |
| `catalogSource` | `sample` | `sample`, `inline` (pasted CSV or JSON), `url`, or `shopify` |
| `catalogText` | empty | CSV or JSON when the source is `inline` |
| `catalogUrl` | empty | Public `http` or `https` file. Private IPs, localhost, and `file://` are rejected |
| `shopifyStoreUrl` | empty | Storefront origin. Only `/products.json` is read |
| `dateStart`, `dateEnd` | empty | Inclusive `YYYY-MM-DD`. Explicit dates use the CPSC recall date |
| `lookbackDays` | `120` | First-run window when there is no watermark and no explicit dates |
| `sinceLastRun` | `true` | After a successful run, request CPSC records by last-publish date from the watermark, with a three-day overlap |
| `includeHealthCanada` | `false` | Also read Health Canada consumer-product open data |
| `stateStoreName` | `cpsc-recall-catalog-matcher-state` | Named key-value store for the watermark and match receipts |
| `stateKey` | `default` | Isolate one catalog or merchant inside that store |
| `alertOnlyOnChange` | `false` | Still checks every item. The dataset keeps new, updated, and resolved rows |
| `fuzzyThreshold` | `80` | Prefix similarity required before a non-exact model is proposed |
| `maxCatalogItems` | `5000` | Extra items are not checked. The run is marked partial and the watermark stays put |

Leave the input empty to run the sample.

### Catalog format

CSV needs a header row. JSON is an array of objects, or `{"items": [...]}`. Recognized columns include `id`, `brand` (also `manufacturer` or `vendor`), `model` (also `mpn`), `upc` (also `gtin`, `ean`, `barcode`), `sku`, `title`, `batch` (also `dateCode`), `lot`, and `manufactureDate` (`YYYY-MM-DD`).

```csv
id,brand,model,upc,sku,title,batch,lot,manufactureDate
fan-1,Hampton Bay,AK396H-MBK,840059614922,HB-1,Halwin ceiling fan,,,
```

A public Shopify URL such as `https://your-store.myshopify.com` is read as `/products.json` only (vendor, barcode, sku, title). The public product feed does not include a manufacturer model number. Barcode matches can still confirm. Brand-only products from that feed usually come back unassessable.

Catalog files are limited to 8 MB. Redirects are checked hop by hop. Hosts that resolve to loopback, private, or link-local addresses are rejected.

### Output rows

Each dataset item is one flat assessment. The run summary is a separate key-value record named `OUTPUT`.

Confirmed identifier match (recall 26702, Hampton Bay Halwin fan):

```json
{
  "catalogItemId": "sample-hampton-halwin",
  "status": "confirmed_identifier_match",
  "statusLabel": "Confirmed identifier match",
  "confidence": 0.99,
  "change": "new",
  "matchedFeatures": ["brand:Hampton Bay", "model:AK396H-MBK", "upc:0840059614922"],
  "missingInformation": [],
  "exclusionNotes": null,
  "recallNumber": "26702",
  "recallUrl": "https://www.cpsc.gov/Recalls/2026/Hampton-Bay-Halwin-52-Inch-Ceiling-Fans-Recalled-Due-to-Impact-and-Injury-Hazards-Manufactured-by-Youngo-Limited",
  "recallDate": "2026-08-13",
  "hazard": "The fan blades can separate from the fan motor flywheel, posing impact and injury hazards to consumers.",
  "remedy": "Consumers should stop using the recalled ceiling fans immediately and contact Youngo for either a full refund...",
  "affectedModelLanguage": "This recall involves Hampton Bay Halwin 52-inch Indoor/Outdoor Ceiling fans with model numbers AK396H-MBK and AK396H-BN.",
  "safetyClearance": false,
  "notASafetyClearance": true,
  "coverageStatus": "complete",
  "recallsChecked": 10,
  "source": "cpsc",
  "jurisdiction": "US"
}
```

Plausible candidate, because the recall names a date code the catalog did not supply (recall 26798):

```json
{
  "catalogItemId": "sample-love-to-dream-undated",
  "status": "plausible_candidate",
  "confidence": 0.64,
  "matchedFeatures": ["brand:Love to Dream", "model:LTD-SM23"],
  "missingInformation": [
    "The recall limits the population with date code or lot 7W15TN, and the catalog item did not supply one."
  ],
  "recallNumber": "26798",
  "recallUrl": "https://cpsc.gov/Recalls/2026/Love-To-Dream-Recalls-Portable-Sleep-Machines-Due-to-Risk-of-Injury-from-Fire-and-Burn-Hazards",
  "safetyClearance": false
}
```

No candidate found. The model matches recall 26749, and the supplied date and lot do not. This is still not a clearance:

```json
{
  "catalogItemId": "sample-victgoal-outside-date",
  "status": "no_candidate_found",
  "confidence": null,
  "matchedFeatures": [],
  "missingInformation": [
    "Checked 10 retrieved recalls. No exact identifier or reviewable brand-and-model candidate was found. This is not a safety clearance."
  ],
  "exclusionNotes": "Excluded CPSC 26749: model HT-006 matches published HT-006, but manufacture date 2024-03-15 is outside November 2025, Production: 2025/11 and supplied batch/lot 2024A0301 is not the published code 2025A1101.",
  "recallNumber": null,
  "safetyClearance": false,
  "notASafetyClearance": true
}
```

Unassessable, brand only (recall 27041). The published arm codes are not in the catalog:

```json
{
  "catalogItemId": "sample-lithonia-brand-only",
  "status": "unassessable",
  "matchedFeatures": ["brand:Lithonia Lighting"],
  "missingInformation": [
    "Catalog item has this brand but no model or UPC. The recall identifies affected units by model, batch, or date, so this row cannot be confirmed or cleared."
  ],
  "recallNumber": "27041",
  "recallUrl": "https://cpsc.gov/Recalls/2027/Lithonia-Lighting-Recalls-RSX-Luminaires-with-Adjustable-Tilt-Arm-Mountings-Due-to-Risk-of-Serious-Injury-from-Impact-Hazard",
  "safetyClearance": false
}
```

`recallsChecked` on a live run is the number of records actually retrieved, which is larger than the ten-record fixture used for the snippets above. Every row also carries `observedAt`, `officialApiUrl`, `evidenceSnippet`, `recallContentHash`, `disclaimer`, the window start and end, and `change` (`new`, `updated`, `unchanged`, `resolved`, or `not_applicable`).

`OUTPUT` in the default key-value store records coverage, counts, charged units, and whether the watermark moved. A blocked retrieval writes `coverageStatus: "blocked"`, charges nothing, and does not replace a saved baseline.

### How a match is decided

1. Exact UPC or GTIN, including a 12-digit UPC-A and the same code as a 13-digit EAN with a leading zero. A UPC hit stays confirmed even if the brand text disagrees. The disagreement is listed in `missingInformation`.
2. Exact model (hyphens and spaces ignored) plus a compatible brand. A required date code, lot, or manufacture window that the catalog does not supply keeps the row plausible. A supplied date outside every published manufacture window, or a conflicting lot, is an exclusion, not a candidate.
3. A model recovered only from the product title is never confirmed. It can be a plausible candidate.
4. A prefix of a published model (for example `BS-FIREPIT` against `BSFIREPIT01`) is a plausible candidate only when the brand agrees and the shorter code covers at least `fuzzyThreshold` percent of the published code. This is not typo matching. `120` does not match `12014`.
5. Brand only, when the recall names models, windows, or codes, is unassessable (up to five recent recalls per item). It is checked, and it is not a candidate charge.
6. No brand and no model or UPC is unassessable and is not charged.
7. No hit is `no_candidate_found`. That sentence is not a safety clearance. A different model from the same brand is intentionally left alone.

A merchant SKU confirms a row only when it equals a model or item number the recall actually published.

### Pricing

This Actor uses pay-per-event pricing:

| Event | When it is charged | Price |
| --- | --- | --- |
| Platform start event | Once per run, charged by Apify | $0.005 |
| `catalog-item-checked` | Once per catalog item actually checked against that run's recalls, including a scheduled run that pushes no new rows | $0.001 |
| `candidate-match` | Once per new or materially changed confirmed or plausible evidence package | $0.20 |

Unassessable rows, no-candidate rows, exclusions, and resolved rows are not `candidate-match` charges. The same evidence package is not charged again on a later run. A failed fetch is not charged. If the run hits your charge limit, the unit that was accepted is delivered, later items are not billed, the watermark does not advance, and `OUTPUT` says the limit was reached.

There is no per-dataset-item event.

Worked examples, plus the $0.005 start event:

| Run | Checks | New candidate packages | Events | With start |
| --- | --- | --- | --- | --- |
| Default sample, first run | 9 | 6 | $1.209 | $1.214 |
| 5,000 SKUs each week, 8 new candidates, earlier candidates unchanged | 5,000 | 8 | $6.60 | $6.605 |
| 50,000 item checks and 150 new candidates | 50,000 | 150 | $80.00 | $80.005 |

A quiet week still costs the per-item check, because each run compares the catalog with the recalls retrieved that day. It does not cost another $0.20 for a candidate whose hazard, remedy, and identifiers did not change. `OUTPUT.estimatedChargeUsdAtSuggestedPrices` estimates the charge using the prices above.

### Repeat runs

State lives in the named key-value store, record `baseline__{stateKey}`. A successful run (`coverageStatus` complete or empty, catalog not truncated, charge limit not hit) stores a per-source watermark. The next run with `sinceLastRun` asks CPSC for records whose last publish date is on or after that watermark minus three days. An errored, blocked, partial, truncated, or charge-limited run does not move the watermark. Receipts for candidate packages that were delivered can still be merged, so a retry does not bill those packages again.

`alertOnlyOnChange` omits unchanged rows from the dataset. The check event is still charged. A previously delivered candidate that disappears from a complete window is pushed as `change: "resolved"`. Disappearance is not a clearance either.

Use a different `stateKey` per merchant if they share one store.

### Health Canada

Off by default, so the daily default run stays on the CPSC API. Turn on `includeHealthCanada` to also read the Recalls and Safety Alerts open-data file and keep rows whose organization is a consumer-product program. Food, health, vehicle, and other programs in that file are skipped. The open data often has no UPC. Its date filter uses **Last updated**, which is not the original publication date. A large file is downloaded on every enabled run.

Contains information licensed under the [Open Government Licence – Canada](https://open.canada.ca/en/open-government-licence-canada).

### Integrations

The dataset is flat, so a webhook, Make, or n8n scenario can map `status`, `catalogItemId`, `sku`, `recallUrl`, `hazard`, and `missingInformation` without unnesting. Google Sheets can take the same CSV columns. Point a monitor at the `OUTPUT` record when you only need coverage and charged-unit counts. Filter `change` to `new` or `updated` if the destination should stay quiet on repeat runs, or set `alertOnlyOnChange` in the input.

### Limits

- This is not a certified safety or compliance service. A person reviews every candidate. False negatives matter: a model number the parser did not see will not be flagged.
- Model numbers are taken from CPSC structured fields when those fields are filled, and otherwise from text near the word "model", hyphenated codes, and quoted codes. All-letter component codes such as `AASP` are not extracted. A brand-only row is the fallback when the recall still has a manufacture window or similar limit.
- Fuzzy matching is prefix overlap, not a typo distance. `TL1302Z` does not propose `TL1302Y`.
- A different model from the same brand is not a candidate.
- At most 4,000 recall records are kept. If the window is larger, the newest 4,000 are kept, coverage is partial, and the watermark does not advance.
- The default catalog cap is 5,000 items (maximum input 50,000). Items past the cap are not checked.
- An empty recall window still charges `catalog-item-checked` and says the assessed scope was empty. A blocked source charges nothing and fails the run.
- Shopify public JSON usually has no manufacturer part number.
- CPSC `RecallNumber` search is a wildcard on the API side. This Actor keeps a record only when the number is an exact match.
- Sample mode pins a few recall numbers so the demo stays populated. Your own catalog does not pin them.

### Run locally

Python 3.12.

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
pytest
python3 -m src
```

`pytest` includes a live CPSC smoke of the default sample. Skip it with `pytest -m "not live"` when you have no network. `ACTOR_TEST_PAY_PER_EVENT=1 python3 -m src` writes a local charging log. Results land in `storage/datasets/default` and `storage/key_value_stores/default/OUTPUT.json`.

On Apify, the default memory is 512 MB and the run timeout is 10 minutes. The image is `apify/actor-python:3.12`. Do not grant the Actor full permissions. It only calls the official CPSC API, the optional Health Canada open-data URL, and the catalog URL you supply.

### Sources

CPSC recall text comes from the public SaferProducts REST API at `https://www.saferproducts.gov/RestWebServices/Recall`. Attribute those records to the U.S. Consumer Product Safety Commission and follow the official recall link on each row. This Actor is an unofficial client.

Health Canada rows, when enabled, come from the public open-data JSON and are under the Open Government Licence – Canada.

The User-Agent identifies `cpsc-recall-catalog-matcher` and `plusonets`. Requests are retried with backoff on rate limits and server errors.

### Contact

Questions about this Actor: plusonecares@gmail.com.

# Actor input Schema

## `catalogSource` (type: `string`):

The built-in sample copies brand, model, and UPC values from recent public CPSC recalls so a demo run returns a review queue.

## `catalogText` (type: `string`):

Used when the source is inline. Include a header row or a JSON array. Recognized columns: brand, model, upc, sku, title, batch, lot, manufactureDate.

## `catalogUrl` (type: `string`):

Public http(s) URL of a CSV or JSON catalog. Private hosts, localhost, and link-local addresses are rejected.

## `shopifyStoreUrl` (type: `string`):

Public storefront origin, for example https://your-store.myshopify.com. Only /products.json is read. Barcode becomes UPC and vendor becomes brand. Model numbers are not in the public feed.

## `dateStart` (type: `string`):

YYYY-MM-DD inclusive. Leave empty to use the saved watermark or the lookback window. An explicit date uses the CPSC recall date, not the last-publish date.

## `dateEnd` (type: `string`):

YYYY-MM-DD inclusive. Leave empty for today.

## `lookbackDays` (type: `integer`):

Used on the first run when no dates and no saved watermark are available.

## `sinceLastRun` (type: `boolean`):

When no explicit dates are set, request CPSC recalls published or updated since the last successful watermark, with a three-day overlap. A failed run does not move the watermark.

## `includeHealthCanada` (type: `boolean`):

Also read the Health Canada Recalls and Safety Alerts open-data JSON and keep consumer-product rows. Medical, food, vehicle, and cannabis rows are skipped. Unofficial.

## `stateStoreName` (type: `string`):

Named key-value store for the watermark and delivered match receipts. Use one store per Apify account. Isolate merchants with the state key.

## `stateKey` (type: `string`):

Isolates watermarks when you monitor more than one catalog. Use a different key per merchant.

## `alertOnlyOnChange` (type: `boolean`):

Every catalog item is still checked. The dataset keeps new, updated, and resolved rows and omits unchanged ones.

## `fuzzyThreshold` (type: `integer`):

0-100. A catalog model that is only a prefix of a published model is a candidate when the shorter code covers at least this percent of the published code. Exact UPCs and exact models ignore this threshold.

## `maxCatalogItems` (type: `integer`):

Items above this cap are not checked. The run is marked partial and the watermark is not moved.

## Actor input object example

```json
{
  "catalogSource": "sample",
  "lookbackDays": 120,
  "sinceLastRun": true,
  "includeHealthCanada": false,
  "stateStoreName": "cpsc-recall-catalog-matcher-state",
  "stateKey": "default",
  "alertOnlyOnChange": false,
  "fuzzyThreshold": 80,
  "maxCatalogItems": 5000
}
```

# Actor output Schema

## `reviewQueue` (type: `string`):

One flat row per catalog assessment or recall candidate. No candidate found is not a safety clearance.

## `summary` (type: `string`):

Coverage, counts, and charged units for this run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/cpsc-recall-catalog-matcher").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/cpsc-recall-catalog-matcher").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call plusonets/cpsc-recall-catalog-matcher --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/cpsc-recall-catalog-matcher"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/nvbapcyF4jmREeeLf/builds/isHbxU4JiJTgYnw5h/openapi.json
