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

  1. Create a catalog with an alert email, a webhook URL or both.
  2. Upload items as JSON or CSV. The response lists the matches found in the recall database right away.
  3. Each day, new matches are sent as alerts.
  4. Review matches and dismiss the ones that do not apply.

Create a catalog: POST /v1/catalogs

FieldMeaning
nameRequired, up to 100 characters.
alert_emailWhere alert emails go.
webhook_urlWhere signed alert payloads are posted. Must be https://.
min_confidenceexact, 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.

FieldMeaning
skuYour id for the item. When missing, the UPC, model, NDC or VIN is used, or item-N.
nameProduct name (up to 300 characters).
brandBrand 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.
upcUPC, EAN or GTIN.
model, ndc, lotModel or part number, National Drug Code, lot or batch code.
vin17-character VIN (validated).
marketplaceamazon, 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:

ColumnAlso accepted
sku
name
brand
upcgtin, 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:

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).

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...:

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"