If your dog's food is recalled, you want to know today, not next month.
I run PerBowl, a site that traces US dog and cat foods back to the companies and plants that make them, with every FDA recall since 2012. When I built recall alerts, I assumed the obvious source was the right one: FDA's classified recall records (the enforcement reports, the ones with a Class I/II/III and full product lists).
It's the wrong source for alerts. I matched 30 dog and cat food recall announcements since January 2024 against FDA's classified records, and the classified record arrived a median of 25 days later, once 91 days later. (The full table and CSV are here.) By then the bag is long empty.
The same-day source: one JSON file
The company announcement, the press release a firm publishes when it recalls a product, shows up on FDA's "Recalls, Market Withdrawals, & Safety Alerts" page the day it's posted. That page is backed by a single JSON file:
https://www.fda.gov/datatables-json/recalls-market-withdrawals.json
No API key, no pagination: about 1,080 rows today, covering every FDA-regulated product (food, drugs, devices, cosmetics, pet food). Each row looks roughly like this:
{
"field_change_date_2": "08/28/2026",
"field_company_name": "Northwest Naturals",
"field_brand_name": "Northwest Naturals",
"field_product_description": "Chicken Recipe …",
"field_recall_reason": "Salmonella, Listeria",
"field_regulated_product_field": "Animal & Veterinary, Pet Food",
"path": "/safety/recalls-market-withdrawals-safety-alerts/…"
}
Some fields contain HTML, so everything goes through a strip-tags-and-decode step first.
Filtering to dog and cat food
"Pet Food" in the product-type field catches most of them. The rest are filed under "Animal & Veterinary" and need a word check on the product, which then has to exclude things that are animal products but not what a dog or cat owner signed up for:
$pet = str_contains( $types, 'Pet Food' )
|| ( str_contains( $types, 'Animal & Veterinary' )
&& preg_match( '/\b(dogs?|cats?|pets?|canine|feline|kitten|puppy|treats?|pig ears?|bully|chews?|jerky|rawhide|recipe|raw diet)\b/i', $product . ' ' . $brand ) );
// Dog and cat only: other animals' feed and veterinary drugs are not what subscribers asked for.
if ( $pet && ! preg_match( '/\b(dogs?|cats?|canine|feline|kitten|puppy)\b/i', $product )
&& preg_match( '/\b(hedgehog|reptile|bird|parrot|fish|aquarium|rabbit|hamster|horse|equine|cattle|swine|poultry|livestock|chicken feed|layer|sheep|lamb|goat)\b|injection|tablets?|sterile/i', $product . ' ' . $brand ) ) {
$pet = false;
}
Two lessons from tuning it on real rows:
- Keep pig ears and chews. Several Salmonella recalls are treats that never say "dog" in the product line.
- "chicken" and "lamb" are not animals here, they're recipes. Which is why "lamb" only excludes a row when no dog or cat word is present, and why "chicken feed" is a phrase, not "chicken".
Only alert on what's new
Every 6 hours the server fetches the file, filters it, and compares each row's path (stable per announcement) with a stored list of paths already seen:
$seen = (array) get_option( 'pb_alerts_seen', array() );
if ( ! $seen ) { // first run: everything already on FDA's list counts as old news
update_option( 'pb_alerts_seen', array_slice( array_keys( $all ), 0, 1000 ), false );
return;
}
foreach ( $all as $path => $a ) {
if ( in_array( $path, $seen, true ) ) continue;
$seen[] = $path;
if ( $a['date'] < $cut ) continue; // an old entry FDA edited: not news
// … match subscribers by brand, send, remember it for later …
}
Two guards matter:
- The first run seeds, it doesn't send. Otherwise switching the feature on would email everyone about the last ten years of recalls.
- Anything dated more than 14 days ago is skipped, because an old entry that FDA edits can turn up looking new.
The announcement is remembered too (date, company, brand), so when FDA's classified record arrives weeks later, subscribers to a specific food get a follow-up that says "confirmed: your barcode is on the list" instead of a second "new recall" email.
Recalls that are never announced
Here's the part I didn't expect: of the 48 dog and cat food recalls FDA classified since 2024, 21 (44%) never appeared on the announcement list at all. Those only exist as classified records, so the alert system has two triggers: announcements from the JSON file (same day), and newly imported classified recalls that match no earlier announcement.
(The window starts in 2024 on purpose: FDA's list keeps far fewer older announcements, so any "never announced" count before then would be wrong.)
WordPress cron needed a nudge
The checks run on WP-Cron, which only fires when someone loads a page. With Cloudflare caching HTML at the edge, most visits never reach PHP at all, so the "every 6 hours" check could go a day without running. The fix is a one-line system crontab that runs due events every 15 minutes:
*/15 * * * * cd /path/to/site && wp cron event run --due-now
Plus a daily watchdog that emails me if the FDA check hasn't succeeded in 13 hours, and a Monday summary email. If the summary doesn't arrive, the scheduled jobs have stopped.
What it does now
New FDA dog and cat food recall announcements go out the same day as emails to subscribers (by brand, by specific food, or all recalls), and as posts on Bluesky and X. You can sign up on PerBowl's recall list, which also shows announcements FDA hasn't classified yet.
The announcement-to-classification data, with every pair of dates, is downloadable as CSV if you'd like to dig into it.
Top comments (0)