ProteinPrice publishes free JSON data feeds. Checked daily via our scraper. Use them however you want: AI training, price tracking apps, comparison tools, research.
ProteinPrice publishes free JSON data feeds covering the entire US retail protein market. Checked daily via our scraper. Use them however you want: AI training, price tracking apps, comparison tools, research.
All feeds are static files served from the same domain as the site. No SDK to install, no auth dance, no quota. Just GET a URL.
Cache-Control.| # | Endpoint | What it returns | Updates |
|---|---|---|---|
| 1 | /data/products.json | Product catalog with prices, specs, images | Every 2h |
| 2 | /data/brands.json | All 28+ brands with descriptions and metadata | On change |
| 3 | /data/retailers.json | 12 retailers we track | On change |
| 4 | /data/scope.json | Definitive scope of retailers, brands, products tracked | On change |
| 5 | /data/scraper_status.json | Live health: per-retailer success rates, last scrape time | Every run (~hourly) |
| 6 | /data/last_scrape.json | Per-product, per-retailer last successful scrape timestamps | Every run |
| 7 | /data/price_lows.json | Lowest price in last 30 days per product | Every run |
| 8 | /data/price_history-YYYY-MM.jsonl | Append-only price change log (1 record per line) | Every change |
| 9 | /data/retailer_urls.json | Direct PDP URLs per product per retailer | On change |
The core feed. Every tracked SKU with current per-retailer prices, sizing, macros, an image URL, and our computed Value Score.
{
"lastUpdated": "2026-05-20T08:47:57Z",
"totalProducts": 199,
"products": [
{
"id": "on-gold-standard-whey-chocolate-5lb",
"brandId": "optimum-nutrition",
"name": "Gold Standard 100% Whey",
"category": "whey-blend",
"flavor": "Double Rich Chocolate",
"sizeG": 2270,
"sizeLb": 5,
"servings": 74,
"proteinG": 24,
"servingG": 30,
"valueScore": 94,
"img": "https://cdn.shopify.com/.../GSW_DRC_5lb_FOP.png",
"prices": {
"walmart": { "price": 54.99, "inStock": true },
"amazon": { "price": 55.49, "inStock": true }
}
}
]
}
| Field | Type | Description |
|---|---|---|
id | string | Stable URL slug, unique per SKU. |
brandId | string | Foreign key to brands.json. |
name | string | Product name as marketed. |
category | enum | whey-blend, whey-isolate, whey-concentrate, casein, plant, mass-gainer, collagen, clear-whey. |
flavor | string | Flavor variant. |
sizeG / sizeLb | number | Tub size in grams and pounds. |
servings | int | Servings per container. |
proteinG | number | Grams of protein per serving. |
servingG | number | Serving size in grams. |
valueScore | int 0–100 | Normalized grams-of-protein-per-dollar score. Higher is better. |
img | URL | Product hero image. |
prices | object | Keyed by retailer ID. Each entry: { price: number, inStock: boolean }. |
curl https://proteinprice.com/data/products.json
const res = await fetch('https://proteinprice.com/data/products.json'); const data = await res.json(); // Sort by Value Score descending const ranked = data.products.sort((a, b) => b.valueScore - a.valueScore); console.log(ranked[0].name, ranked[0].valueScore);
import requests data = requests.get('https://proteinprice.com/data/products.json').json() # Cheapest current price across all retailers, per product for p in data['products'][:5]: in_stock = [v['price'] for v in p['prices'].values() if v.get('inStock')] if in_stock: print(p['name'], '$', min(in_stock))
All 28+ brands with descriptions, founding year, country, and visual styling values (used to render brand cards on the site).
{
"lastUpdated": "2026-05-20T08:30:00Z",
"totalBrands": 34,
"brands": [
{
"id": "optimum-nutrition",
"name": "Optimum Nutrition",
"shortName": "ON",
"tubColor": ["#C0392B", "#7B241C"],
"lidColor": "#E74C3C",
"cardBg": "linear-gradient(145deg,#FDF5E8,#F5E6D0)",
"desc": "The world's best-selling protein brand...",
"founded": 1986,
"country": "US"
}
]
}
| Field | Type | Description |
|---|---|---|
id | string | Stable slug. |
name | string | Full brand name. |
shortName | string | 2–3 letter abbreviation used in UI. |
desc | string | One-line marketing description. |
founded | int | Year founded. |
country | ISO-2 | Country of brand HQ. |
tubColor / lidColor / cardBg | style | Visual styling values for rendering brand cards. |
brandId on a product into a display name and logo styling.curl https://proteinprice.com/data/brands.json
const { brands } = await fetch('https://proteinprice.com/data/brands.json').then(r => r.json()); const byId = Object.fromEntries(brands.map(b => [b.id, b])); console.log(byId['optimum-nutrition'].name);
import requests brands = requests.get('https://proteinprice.com/data/brands.json').json()['brands'] us_brands = [b for b in brands if b['country'] == 'US'] print(len(us_brands), 'US-based brands')
All 12 US retailers we track. Each has an ID (used as a key in products.json → prices), a display name, a brand color, and a homepage URL.
{
"lastUpdated": "2026-05-20T08:00:00Z",
"totalRetailers": 12,
"retailers": [
{
"id": "walmart",
"name": "Walmart",
"shortName": "W",
"color": "#0071CE",
"bg": "#DBEAFE",
"url": "https://walmart.com"
}
]
}
| Field | Type | Description |
|---|---|---|
id | string | Stable slug: same key used inside each product's prices object. |
name | string | Display name. |
shortName | string | 1–3 character label. |
color / bg | hex | Brand color and background tint. |
url | URL | Retailer homepage URL. |
curl https://proteinprice.com/data/retailers.json
const { retailers } = await fetch('https://proteinprice.com/data/retailers.json').then(r => r.json()); const retailerName = Object.fromEntries(retailers.map(r => [r.id, r.name]));
import requests retailers = requests.get('https://proteinprice.com/data/retailers.json').json()['retailers'] for r in retailers: print(f"{r['id']:<18} {r['name']}")
The definitive "lock-in" document for what ProteinPrice tracks. Lists active retailers, rejected retailers (and why), tracked brands, Phase 2 candidates, active categories, and scraping SLOs.
{
"version": "1.0",
"lastUpdated": "2026-05-20T08:00:00Z",
"philosophy": "Lock-in document. Defines exactly which retailers, brands...",
"retailers": {
"active": [
{ "id": "amazon", "name": "Amazon", "tier": 1, "expectedAvailability": "blocked-without-api" },
{ "id": "walmart", "name": "Walmart", "tier": 1, "expectedAvailability": "blocked" }
],
"rejected": [
{ "id": "supplementwarehouse", "reason": "Smaller catalog..." }
],
"totalActive": 12
},
"brands": {
"tracked": ["optimum-nutrition", "dymatize", "myprotein", /* ... */],
"totalTracked": 28
},
"categoriesActive": ["whey-blend", "whey-isolate", "casein", /* ... */]
}
curl https://proteinprice.com/data/scope.json
const scope = await fetch('https://proteinprice.com/data/scope.json').then(r => r.json()); console.log('Active retailers:', scope.retailers.active.map(r => r.id)); console.log('Tracked brands:', scope.brands.totalTracked);
import requests scope = requests.get('https://proteinprice.com/data/scope.json').json() tier1 = [r for r in scope['retailers']['active'] if r['tier'] == 1] print('Tier-1 retailers:', [r['name'] for r in tier1])
Live operational health of the scraping pipeline. Includes the last 10 runs, per-retailer 24h success rates, status flags, and a human-readable summary.
{
"lastRun": "2026-05-20T08:49:25Z",
"lastSuccessRate": 0.0,
"trend": "stable",
"recent10Runs": [
{
"runId": "2026-05-20T08:29:07Z",
"durationSeconds": 38,
"productsScraped": 3,
"totalAttempts": 16,
"successes": 1,
"successRate": 0.062,
"rejections": { "swing": 2, "blocked": 3, "failed_fetch": 6 }
}
],
"retailerHealth": {
"amazon": {
"status": "blocked",
"last24hSuccessRate": 0.408,
"last5RunsSuccessRate": 0.0,
"lastSuccess": "2026-05-20T08:37:43Z"
}
},
"summary": "Last run scraped 3 products. 0 retailers contributing real prices..."
}
| Field | Type | Description |
|---|---|---|
lastRun | ISO-8601 | Timestamp of the most recent scraper invocation. |
lastSuccessRate | float 0–1 | Successes / attempts on the most recent run. |
trend | enum | improving, stable, or degrading. |
recent10Runs[] | array | Per-run telemetry (timestamps, attempts, rejections by reason). |
retailerHealth | object | Per-retailer status, 24h success rate, last successful scrape. |
summary | string | Human-readable one-line status. |
curl https://proteinprice.com/data/scraper_status.json
const s = await fetch('https://proteinprice.com/data/scraper_status.json').then(r => r.json()); const minutesSince = (Date.now() - new Date(s.lastRun)) / 60000; console.log(`Last scrape: ${minutesSince.toFixed(0)}m ago: ${s.summary}`);
import requests s = requests.get('https://proteinprice.com/data/scraper_status.json').json() blocked = [r for r, h in s['retailerHealth'].items() if h['status'] == 'blocked'] print('Blocked retailers:', blocked)
Per-product, per-retailer ISO timestamps recording the last successful price scrape. Use to tell which prices are fresh vs. stale.
{
"body-fortress-whey-protein-chocolate-5lb": {
"amazon": "2026-05-20T08:41:49Z",
"target": "2026-05-20T08:41:49Z",
"walmart": "2026-05-20T08:41:49Z"
},
"bsn-syntha6-chocolate-5lb": {
"amazon": "2026-05-20T08:37:43Z",
"bodybuilding": "2026-05-20T08:37:43Z",
"gnc": "2026-05-20T08:37:43Z"
}
}
| Path | Type | Description |
|---|---|---|
{productId} | object | Keyed by product ID (matches products.json). |
{productId}.{retailerId} | ISO-8601 | Timestamp of last successful scrape for that product at that retailer. |
curl https://proteinprice.com/data/last_scrape.json
const last = await fetch('https://proteinprice.com/data/last_scrape.json').then(r => r.json()); const ts = last['on-gold-standard-whey-chocolate-5lb']?.amazon; console.log('Amazon price verified at:', ts);
from datetime import datetime, timezone import requests last = requests.get('https://proteinprice.com/data/last_scrape.json').json() now = datetime.now(timezone.utc) for pid, retailers in last.items(): for r, ts in retailers.items(): age_h = (now - datetime.fromisoformat(ts.replace('Z', '+00:00'))).total_seconds() / 3600 if age_h > 24: print(f"{pid} / {r}: stale ({age_h:.1f}h)")
For each product, the lowest observed price in the last 30 days, plus the retailer and date: and the discount the current best price represents off that 30-day low.
{
"lastComputed": "2026-05-20T08:41:49Z",
"windowDays": 30,
"lows": {
"on-gold-standard-whey-chocolate-5lb": {
"30d_low": 56.99,
"30d_low_date": "2026-05-20T08:37:43Z",
"30d_low_retailer": "amazon",
"current_low": 56.99,
"discount_from_30d": 0.0
}
}
}
| Field | Type | Description |
|---|---|---|
30d_low | USD | Lowest price observed in the last 30 days. |
30d_low_date | ISO-8601 | When that low was observed. |
30d_low_retailer | string | Retailer ID where the low was seen. |
current_low | USD | Today's best price across all retailers. |
discount_from_30d | float | Fractional discount: (30d_low − current_low) / 30d_low. 0 means current price equals the 30d low. |
discount_from_30d ≥ 0.1 (10%+ off).curl https://proteinprice.com/data/price_lows.json
const { lows } = await fetch('https://proteinprice.com/data/price_lows.json').then(r => r.json()); const deals = Object.entries(lows) .filter(([, v]) => v.discount_from_30d >= 0.1) .sort((a, b) => b[1].discount_from_30d - a[1].discount_from_30d); console.log('Best deals right now:', deals);
import requests lows = requests.get('https://proteinprice.com/data/price_lows.json').json()['lows'] deals = [(pid, v) for pid, v in lows.items() if v['discount_from_30d'] >= 0.1] for pid, v in deals: print(f"{pid}: {v['discount_from_30d']*100:.0f}% off")
JSON-Lines append-only log of every detected price change. One record per line. Files rotate monthly (YYYY-MM). Replace 2026-05 with the year-month you want.
{"newPrice": 56.99, "oldPrice": 55.49, "productId": "on-gold-standard-whey-chocolate-5lb", "retailer": "amazon", "source": "scrape", "ts": "2026-05-20T08:37:43Z", "urlSource": "search"}
{"newPrice": 28.99, "oldPrice": 26.49, "productId": "on-gold-standard-whey-chocolate-2lb", "retailer": "amazon", "source": "scrape", "ts": "2026-05-20T08:37:43Z", "urlSource": "search"}
{"newPrice": 59.99, "oldPrice": 65.99, "productId": "dymatize-iso100-birthday-cake-5lb", "retailer": "amazon", "source": "scrape", "ts": "2026-05-20T08:37:43Z", "urlSource": "search"}
| Field | Type | Description |
|---|---|---|
ts | ISO-8601 | When the change was detected. |
productId | string | Product slug. |
retailer | string | Retailer ID. |
oldPrice | USD or null | Previous price (null on first observation). |
newPrice | USD | New observed price. |
source | enum | scrape, manual, api. |
urlSource | enum | pdp (direct product URL) or search (search-result fallback). |
curl https://proteinprice.com/data/price_history-2026-05.jsonl
const txt = await fetch('https://proteinprice.com/data/price_history-2026-05.jsonl').then(r => r.text()); const rows = txt.trim().split('\n').map(JSON.parse); const forProduct = rows.filter(r => r.productId === 'on-gold-standard-whey-chocolate-5lb'); console.log(`${forProduct.length} price changes this month`);
import json, requests url = 'https://proteinprice.com/data/price_history-2026-05.jsonl' rows = [json.loads(line) for line in requests.get(url).text.splitlines() if line] drops = [r for r in rows if r['oldPrice'] and r['newPrice'] < r['oldPrice']] print(len(drops), 'price drops this month')
Per-product, per-retailer verified product-detail-page URLs. Useful for building "buy at Walmart / iHerb / GNC" buttons that go directly to the right PDP.
{
"lastUpdated": "2026-05-20T09:30:00Z",
"totalProducts": 86,
"coverage": {
"walmart": 32, "iherb": 19, "gnc": 14,
"bodybuilding": 34, "target": 9
},
"urls": {
"on-gold-standard-whey-chocolate-5lb": {
"walmart": "https://www.walmart.com/ip/.../32686992",
"iherb": "https://www.iherb.com/pr/.../27509",
"gnc": "https://www.gnc.com/whey-protein/GoldStandard.html",
"bodybuilding": "https://www.bodybuilding.com/store/.../gold-standard-whey-protein.html",
"target": "https://www.target.com/p/.../A-89392663"
}
}
}
| Path | Type | Description |
|---|---|---|
coverage.{retailerId} | int | Number of products with a verified PDP at that retailer. |
urls.{productId}.{retailerId} | URL | Direct PDP URL. |
| Amazon is intentionally omitted (use Amazon PA-API for affiliate-compliant links). | ||
curl https://proteinprice.com/data/retailer_urls.json
const { urls } = await fetch('https://proteinprice.com/data/retailer_urls.json').then(r => r.json()); const walmartUrl = urls['on-gold-standard-whey-chocolate-5lb']?.walmart;
import requests data = requests.get('https://proteinprice.com/data/retailer_urls.json').json() print('Coverage per retailer:', data['coverage']) print('Walmart URL:', data['urls']['on-gold-standard-whey-chocolate-5lb']['walmart'])
Five concrete recipes that combine the endpoints above.
Load products.json, compute the in-stock minimum across each product's prices, then sort.
import requests products = requests.get('https://proteinprice.com/data/products.json').json()['products'] def min_price(p): in_stock = [v['price'] for v in p['prices'].values() if v.get('inStock')] return min(in_stock) if in_stock else float('inf') cheapest = sorted(products, key=min_price)[:10] for p in cheapest: print(f"${min_price(p):>6.2f} {p['name']} ({p['flavor']})")
Read the monthly price_history-YYYY-MM.jsonl file and filter on price decreases.
import json, requests url = 'https://proteinprice.com/data/price_history-2026-05.jsonl' rows = [json.loads(l) for l in requests.get(url).text.splitlines() if l] drops = [r for r in rows if r['oldPrice'] and r['newPrice'] < r['oldPrice']] drops.sort(key=lambda r: (r['newPrice'] - r['oldPrice']) / r['oldPrice']) for d in drops[:5]: pct = (d['newPrice'] - d['oldPrice']) / d['oldPrice'] * 100 print(f"{pct:+.1f}% {d['productId']} @ {d['retailer']}")
The valueScore field already encodes "g of protein per dollar at best retailer," normalized 0–100. Just sort descending.
const { products } = await fetch('/data/products.json').then(r => r.json()); const top = products .filter(p => Object.values(p.prices).some(v => v.inStock)) .sort((a, b) => b.valueScore - a.valueScore) .slice(0, 20); console.table(top.map(p => ({ name: p.name, score: p.valueScore })));
Join products.json with itself on brandId, then count which retailer is cheapest most often.
import requests, collections products = requests.get('https://proteinprice.com/data/products.json').json()['products'] brand = 'optimum-nutrition' wins = collections.Counter() for p in products: if p['brandId'] != brand: continue instock = {r: v['price'] for r, v in p['prices'].items() if v.get('inStock')} if instock: wins[min(instock, key=instock.get)] += 1 print(f"Cheapest retailer for {brand}: {wins.most_common(3)}")
Poll price_lows.json daily. When discount_from_30d crosses your threshold for a product on your watchlist, send a notification.
import requests WATCHLIST = {'on-gold-standard-whey-chocolate-5lb', 'dymatize-iso100-chocolate-5lb'} THRESHOLD = 0.10 # 10% off 30-day low lows = requests.get('https://proteinprice.com/data/price_lows.json').json()['lows'] for pid in WATCHLIST & lows.keys(): v = lows[pid] if v['discount_from_30d'] >= THRESHOLD: print(f"ALERT: {pid} is {v['discount_from_30d']*100:.0f}% off: ${v['current_low']}") # send_email() / send_push() / etc.
User-Agent if you're at scale. MyProteinTracker/1.2 ([email protected]) is fine.Current version: v1 (implicit: all paths above are unversioned). We treat the v1 schema as a stable contract.
When we ship a breaking change, a /api/v2/ tree will appear and the old paths will continue to work in parallel. v1 stays alive for at least 12 months after v2 launches.
Non-breaking changes (new optional fields, new endpoints, more products) ship at any time. Subscribe to our blog or follow our repo for change announcements.
We'd love to hear about it: and possibly feature it.