Catalog monitoring
A catalog is a list of the items you sell. Every item is checked against the recall database when you upload it, and again against every new or changed recall the daily pipeline brings in.
The flow
- Create a catalog with an alert email, a webhook URL or both.
- Upload items as JSON or CSV. The response lists the matches found in the recall database right away.
- Each day, new matches are sent as alerts.
- Review matches and dismiss the ones that do not apply.
Create a catalog: POST /v1/catalogs
| Field | Meaning |
|---|---|
name | Required, up to 100 characters. |
alert_email | Where alert emails go. |
webhook_url | Where signed alert payloads are posted. Must be https://. |
min_confidence | exact, strong or possible (default). Alerts are sent for matches at or above this level. |
Request
curl -s -X POST https://api.recallsapi.com/v1/catalogs \
-H "Authorization: Bearer $RECALLSAPI_KEY" \
-H "content-type: application/json" \
-d '{"name": "Main shop", "alert_email": "alerts@example.com", "webhook_url": "https://example.com/hooks/recalls", "min_confidence": "strong"}'
Response (201)
{
"id": "cat_N0ryFwMVD6ENH8Hb",
"account_id": "acct_7QmV2kR9sLx4TzNa",
"name": "Main shop",
"alert_email": "alerts@example.com",
"webhook_url": "https://example.com/hooks/recalls",
"min_confidence": "strong",
"created_at": "2026-10-05T23:06:11.628Z",
"webhook_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
Store webhook_secret now: it is returned only when the catalog is created, and it is what you use to verify webhook signatures.
The number of catalogs per account depends on the plan; going over answers 402 plan_limit. GET /v1/catalogs lists your catalogs with their item and open match counts.
Upload items: POST /v1/catalogs/{id}/items
Send JSON {"items": [...]}, or CSV with content-type: text/csv. Up to 5,000 rows per upload (CSV up to 10 MB, JSON up to 5 MB); marketplace exports can be sent as they come, and only the columns below are read. The number of monitored products depends on the plan and counts across all catalogs on the account. An item with the same sku as an existing one updates it: it keeps its item id and its matches, and dismissed matches stay dismissed. If its product fields changed, its open matches are cleared and it is matched again. Only SKUs new to the catalog count toward the plan's limit.
| Field | Meaning |
|---|---|
sku | Your id for the item. When missing, the UPC, model, NDC or VIN is used, or item-N. |
name | Product name (up to 300 characters). |
brand | Brand or manufacturer. Helps a lot: it turns many model and lot hits from possible into strong, and it lets new recalls from that firm find the item. |
upc | UPC, EAN or GTIN. |
model, ndc, lot | Model or part number, National Drug Code, lot or batch code. |
vin | 17-character VIN (validated). |
marketplace | amazon, walmart, ebay, shopify, etsy, tiktok, temu or other (anything else becomes other). A label for your own sorting. |
Every item needs at least one of name, upc, model, ndc or vin. Values are trimmed and cut at 100 characters (300 for name). If any item is invalid, nothing is saved and the response lists the problems in details.
CSV format
The first row is the header. Header names are lowercased and any run of spaces or punctuation becomes _, then matched against these accepted column names:
| Column | Also accepted |
|---|---|
sku | |
name | |
brand | |
upc | gtin, ean, barcode |
model | |
ndc | |
lot | |
vin | |
marketplace |
So SKU, UPC and Barcode work as headers, but Product Name (read as product_name) and Lot Number (lot_number) do not: rename those columns to name and lot in your marketplace export. Other columns are ignored. Fields with commas go in double quotes, "" inside quotes is a literal quote, a UTF-8 byte order mark is ignored and blank rows are skipped.
catalog.csv
sku,name,brand,upc,model,lot,marketplace
SPR-5OZ,Crunchy Protein Sprout Mix 5 oz,Everything Sprouts,0 87906-01018 7,,,amazon
LAMP-2,"Desk lamp, black",Acme,,DL-2040,,walmart
Request
curl -s -X POST https://api.recallsapi.com/v1/catalogs/cat_N0ryFwMVD6ENH8Hb/items \
-H "Authorization: Bearer $RECALLSAPI_KEY" \
-H "content-type: text/csv" \
--data-binary @catalog.csv
Response (200, long text fields shortened on this page)
{
"saved": 2,
"skipped": 0,
"matches": [
{
"sku": "SPR-5OZ",
"confidence": "exact",
"reasons": [
"gtin 00087906010187",
"brand"
],
"recall": {
"id": "fda-H-1339-2026",
"agency": "FDA",
"source": "fda_food",
"source_id": "H-1339-2026",
"category": "food",
"title": "Everything Sprouts, LLC: Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adz...",
"description": "Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adzuki, Lentils, Green Pea, ...",
"hazard": "Sprouts may be contaminated with STEC E. coli and/or Salmonella.",
"remedy": null,
"classification": "Class I",
"status": "ongoing",
"firms": [
"Everything Sprouts, LLC"
],
"products": [
"Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adzuki, Lentils, Green Pea, ..."
],
"distribution": "MN, WI",
"units": "201.56 pounds",
"flags": [],
"recall_date": "2026-08-22",
"report_date": "2026-09-23",
"url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22H-1339-2026%22",
"source_url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22H-1339-2026%22",
"as_of": "2026-10-06T01:18:39.595Z"
}
}
]
}
Upload matches are returned for every confidence level, whatever the catalog's min_confidence. They are recorded as already alerted: the upload response is the alert for them, so they do not trigger an email or webhook later.
Items with a vin are decoded with NHTSA vPIC at upload and matched to every NHTSA campaign for that make, model and year, at upload and whenever a new campaign arrives. These matches are possible: confirm VIN-specific open status at nhtsa.gov/recalls, or check one vehicle with /v1/vehicles.
GET /v1/catalogs/{id}/items returns items sorted by SKU, up to 5,000 per page (limit, 1 to 5,000). When there are more, next_cursor is set: pass it as cursor for the next page.
Daily matching
Once a day the pipeline fetches new and changed recalls from every source and matches each one against every catalog in two ways:
- Identifiers. Your items' normalized GTIN, NDC (package and product), model and lot codes are joined against the identifiers extracted from the recall.
- Brand and name. When your item's brand matches the recalling firm or the start of the title or a product name, the item is scored by brand and name overlap.
Each item and recall pair is recorded once, with the date it first matched. Confidence follows the same rules as /v1/check.
List matches: GET /v1/catalogs/{id}/matches
status is open (default), dismissed or all. Newest first, 100 per page by default (limit, 1 to 5,000). When there are more, next_cursor is set: pass it as cursor for the next page.
Request
curl -s "https://api.recallsapi.com/v1/catalogs/cat_N0ryFwMVD6ENH8Hb/matches?status=open" \
-H "Authorization: Bearer $RECALLSAPI_KEY"
Response (200, long text fields shortened on this page)
{
"matches": [
{
"sku": "SPR-5OZ",
"item_id": "itm_q91fCw4JJsu1tqI6",
"item_name": "Crunchy Protein Sprout Mix 5 oz",
"confidence": "exact",
"reasons": [
"gtin 00087906010187",
"brand"
],
"first_matched": "2026-10-06T02:11:49.808Z",
"alerted_at": "2026-10-06T02:11:49.808Z",
"dismissed_at": null,
"recall": {
"id": "fda-H-1339-2026",
"agency": "FDA",
"source": "fda_food",
"source_id": "H-1339-2026",
"category": "food",
"title": "Everything Sprouts, LLC: Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adz...",
"description": "Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adzuki, Lentils, Green Pea, ...",
"hazard": "Sprouts may be contaminated with STEC E. coli and/or Salmonella.",
"remedy": null,
"classification": "Class I",
"status": "ongoing",
"firms": [
"Everything Sprouts, LLC"
],
"products": [
"Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adzuki, Lentils, Green Pea, ..."
],
"distribution": "MN, WI",
"units": "201.56 pounds",
"flags": [],
"recall_date": "2026-08-22",
"report_date": "2026-09-23",
"url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22H-1339-2026%22",
"source_url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22H-1339-2026%22",
"as_of": "2026-10-06T01:18:39.595Z"
}
}
],
"next_cursor": null
}
Dismiss a match
When you have reviewed a match and it does not apply to your product, dismiss it. Dismissed matches are not alerted and drop out of the open list.
POST /v1/catalogs/{id}/matches/{item_id}/{recall_id}/dismiss
curl -s -X POST https://api.recallsapi.com/v1/catalogs/cat_N0ryFwMVD6ENH8Hb/matches/itm_q91fCw4JJsu1tqI6/fda-H-1339-2026/dismiss \
-H "Authorization: Bearer $RECALLSAPI_KEY"
Response (200)
{
"dismissed": true
}
If there is no open match for that item and recall, the answer is 404 not_found.
Delete a catalog: DELETE /v1/catalogs/{id}
Deletes the catalog, its items and its matches right away, and answers {"deleted": true}.
Alerts
Alerts go out after the daily pipeline run for every catalog with new matches at or above its min_confidence, on the plans that include alerts (see pricing).
- Email to
alert_email: subjectRecall alert: N matches in <catalog name>, one entry per match with SKU, confidence, agency, date, title, reasons and the agency link. - Webhook: a
POSTtowebhook_urlwith a JSON body,content-type: application/json,user-agent: recallsapi-webhooks/1and anx-recallsapi-signatureheader. Answer with any 2xx status within 10 seconds.
A catalog's matches are marked delivered when at least one channel succeeds. If every channel fails, they stay queued and are retried on later daily runs, about 1, 2, 4 and 8 days after each failure. After 5 failed attempts they are no longer sent, and they stay open in the API and dashboard.
Webhook body (long text fields shortened on this page)
{
"type": "recall.matches",
"catalog_id": "cat_N0ryFwMVD6ENH8Hb",
"created_at": "2026-10-06T09:20:00.000Z",
"matches": [
{
"item_id": "itm_q91fCw4JJsu1tqI6",
"sku": "SPR-5OZ",
"item_name": "Crunchy Protein Sprout Mix 5 oz",
"brand": "Everything Sprouts",
"marketplace": "amazon",
"confidence": "exact",
"reasons": [
"gtin 00087906010187",
"brand"
],
"recall": {
"id": "fda-H-1339-2026",
"agency": "FDA",
"source": "fda_food",
"source_id": "H-1339-2026",
"category": "food",
"title": "Everything Sprouts, LLC: Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adz...",
"description": "Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adzuki, Lentils, Green Pea, ...",
"hazard": "Sprouts may be contaminated with STEC E. coli and/or Salmonella.",
"remedy": null,
"classification": "Class I",
"status": "ongoing",
"firms": [
"Everything Sprouts, LLC"
],
"products": [
"Everything Sprouts Crunchy Protein Sprout Mix, containing Alfalfa Fenugreek, Cabbage, Mung, Adzuki, Lentils, Green Pea, ..."
],
"distribution": "MN, WI",
"units": "201.56 pounds",
"flags": [],
"recall_date": "2026-08-22",
"report_date": "2026-09-23",
"url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22H-1339-2026%22",
"source_url": "https://api.fda.gov/food/enforcement.json?search=recall_number:%22H-1339-2026%22",
"as_of": "2026-10-06T01:18:39.595Z"
}
}
]
}
Verify the webhook signature
The header looks like x-recallsapi-signature: t=1791278400,v1=87e903b6...:
tis the Unix time, in seconds, when the delivery was sent.v1is the lowercase hex HMAC-SHA256 of the string{t}.{raw request body}, keyed with the catalog'swebhook_secret.
Compute the HMAC over the raw bytes you received (before any JSON parsing) and compare in constant time. Use t to reject replays of old deliveries; each delivery is stamped when it is sent, so the examples accept timestamps up to five minutes old.
Node.js
// Verifies x-recallsapi-signature on an incoming webhook (Node 18+, no packages).
import crypto from 'node:crypto';
import http from 'node:http';
const SECRET = process.env.RECALLSAPI_WEBHOOK_SECRET; // whsec_... returned by POST /v1/catalogs
/** True when the signature header matches the raw body and the timestamp is within the tolerance. */
export function verifySignature(header, rawBody, secret = SECRET, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
if (!t || !/^[0-9a-f]{64}$/.test(parts.v1 || '')) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
if (!verifySignature(req.headers['x-recallsapi-signature'], raw)) {
res.writeHead(401).end('bad signature');
return;
}
const event = JSON.parse(raw.toString('utf8'));
for (const m of event.matches) console.log(m.sku, m.confidence, m.recall.id, m.recall.url);
res.writeHead(200).end('ok');
});
}).listen(8080);
Python
# Verifies x-recallsapi-signature on an incoming webhook (Python 3, standard library only).
import hashlib
import hmac
import os
import time
SECRET = os.environ["RECALLSAPI_WEBHOOK_SECRET"].encode() # whsec_... returned by POST /v1/catalogs
def verify_signature(header: str, raw_body: bytes, secret: bytes = SECRET, tolerance: int = 300) -> bool:
"""True when the signature header matches the raw body and the timestamp is within the tolerance."""
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask example:
# @app.post("/hooks/recalls")
# def recalls_hook():
# if not verify_signature(request.headers.get("x-recallsapi-signature"), request.get_data()):
# return "bad signature", 401
# for m in request.get_json()["matches"]:
# print(m["sku"], m["confidence"], m["recall"]["id"], m["recall"]["url"])
# return "ok"