The SA Grocery Price API
Real shelf prices from South Africa’s big supermarkets, in clean JSON. Not a survey - the same data behind LoyaltyHub, updated every Tuesday and Friday.
Real prices
Woolworths, Checkers, Shoprite, Pick n Pay, Clicks, Dis-Chem and Makro - tens of thousands of products.
Freshness built in
Every response tells you exactly when the data was last updated.
Simple auth
One API key. Bearer token or x-api-key header. JSON everywhere.
Quickstart
Create a key below, then:
curl https://loyaltyhub.co.za/api/v1/prices?search=milk \ -H "Authorization: Bearer lh_live_your_key_here"
Endpoints
GET /api/v1/retailersRetailers we track, each with its last-updated time.GET /api/v1/indexMonthly SA Grocery Price Index (aggregated).GET /api/v1/prices?search=milkCurrent prices. Filter by barcode(s), retailer, department, category, search or changed_since.GET /api/v1/products?barcode=6001030002075One product, grouped across every retailer. Use barcodes= for up to 50 at once, or search= (ranked).GET /api/v1/history?barcode=6001030002075Price history for a product.GET /api/v1/regionsThe price regions for ?region= (Checkers and Shoprite) and the store each comes from. No key needed.GET /api/v1/departmentsThe department ids to filter by (e.g. dairy-eggs, beverages). No key needed.GET /api/v1/snapshotFeed plan: links to the full catalogue as gzipped CSV and JSON Lines, rebuilt after every retailer update.GET /api/v1/shop-prices?province=gautengPrices uploaded by independent shops. Filter by barcode(s), shop, province, search or changed_since.Every response includes an updated_at timestamp and your remaining monthly quota.
Guides
What a price represents
Each retailer has one price per product. It is the price shown on the retailer’s website or online catalogue when we collected it, not the shelf price at a particular branch. You can’t request a specific store or delivery area; for Checkers and Shoprite you can ask for a region (see Regional prices). Every row and offer carries price_basis, and /retailers gives the same per retailer with a short price_source description:
online: the website price with no store or delivery area selected (Shoprite, Pick n Pay, Woolworths, Makro, Clicks, Dis-Chem).single_store: the online catalogue of one store (Checkers).
product_url links to the product on the retailer’s site.
Promotions
For Checkers, Dis-Chem, Makro, Shoprite and Woolworths we collect promotions, so on_promotion is true or false:
was_price: the regular price whilepriceis a marked-down one;nullotherwise.promotion: the retailer’s wording of a multibuy offer, e.g. “Buy any 2 for R125” (Woolworths).pricestays the price of one item; the offer isn’t applied to it.promotion_loyalty:truewhen that offer needs the retailer’s loyalty card (WRewards, Checkers Xtra Savings).loyalty_price: Checkers only, the price of one item with an Xtra Savings card when a member deal sets one (“Now R44.99”, “Save R50”, “Save 20%”);nullotherwise. Member multibuys (“Buy 2 For R28”) are inpromotiononly.pricestays the shelf price.
For the other retailers we don’t collect this: on_promotion is null (unknown) and price is whatever the site showed, promotional or not. Promotion end dates and in-store-only specials aren’t available for any retailer. Checkers deals are those of the store its prices come from.
Regional prices
Checkers and Shoprite charge different prices in different parts of the country. Stores in the same region share prices; between regions about half of all products differ. Add ?region= to /prices or /products to get their prices for one of five regions:
gauteng: Gauteng, Mpumalanga, Limpopo, North West.western_cape: Western Cape, except the Garden Route.eastern_cape: Eastern Cape, Garden Route (George).kwazulu_natal: KwaZulu-Natal.free_state: Free State, Northern Cape.
A province that shares another’s prices is accepted too (limpopo, northern_cape). With ?region=, every row and offer gains a region field. Where we have that region’s price, price, was_price and last_seen_at are the region’s, region is the region id and price_basis is regional_store. Otherwise the row is unchanged and region is null: that covers the other retailers, which have one price nationally online, and products the region’s store doesn’t list.
Each region’s prices come from the online catalogue of one store in that region, named in GET /api/v1/regions (no key needed). They are a good guide to the region, not a guarantee for every branch. On /prices, sorting, total and changed_since still follow the default price. Without ?region= responses are exactly as before. Price history is not kept per region.
Coverage & history
Every plan reads the same data; plans differ only in limits and history depth. Coverage varies by product, so a missing retailer means “not in our data”, not “not sold there”:
- Products are matched across retailers by barcode. Own-brand and fresh items are often at one retailer only.
- Most Makro listings have no barcode yet (they are being added), so they don’t appear in barcode lookups; find them with
/prices?search=. Woolworths own-brand products have none. - Woolworths covers food, wine, baby, beauty and home, but not clothing. Clicks and Dis-Chem are mainly health, beauty and pharmacy.
- SPAR, Boxer and Food Lover’s Market prices aren’t collected.
sizeandunit_pricearenullwhen the product name doesn’t state a size.
Price history starts in June 2026 for Shoprite, Checkers, Pick n Pay, Woolworths, Clicks and Makro, and in late September 2026 for Dis-Chem. How much of it /history returns depends on your plan.
Local shop prices
Independent shops upload their own prices. They’re kept apart from the supermarket data: use /shop-prices, or add include_shops=true to /products for a local_shops list next to offers (prices, min_price and cheapest_retailer stay supermarket-only). By default only shop prices whose barcode we have verified are returned; add include_unverified=true for the rest, each marked verified: false.
Pagination
List endpoints return up to limit rows (max 500, default 50). Use offset to page through the rest - the meta tells you total, has_more and the next_offset to request.
GET /api/v1/prices?search=milk&limit=500&offset=0 GET /api/v1/prices?search=milk&limit=500&offset=500 # next page
Freshness & departments
/prices and /products only return prices seen on the retailer’s site in the last 30 days, so delisted products drop out. Widen that with max_age_days (up to 365); each row’s last_seen_at says exactly when we last saw it. Retailers usually update on Tuesdays and Fridays: /retailers shows when each one last did, and changed_since on /prices returns only what changed after your last sync. Every row also has a department (one fixed list across all retailers) alongside the retailer’s own category label.
GET /api/v1/prices?department=dairy-eggs&retailer=pnp GET /api/v1/prices?search=milk&max_age_days=90
Bulk lookups & incremental sync
Look up to 50 barcodes in one call with barcodes= (comma-separated) - one request, one call against your quota. To keep a copy in sync, pass changed_since (ISO timestamp) and you get only prices that changed or first appeared since then; each row’s price_changed_at tells you when.
GET /api/v1/products?barcodes=6001030002075,5000112607413 GET /api/v1/prices?changed_since=2026-09-01T00:00:00Z&limit=500
Unit prices
Rows include the pack size (normalised to kg, litres or items, parsed from the product name) and a unit_price, e.g. {"value": 18.99, "per": "l"}, so different pack sizes compare directly. Both are null when the size isn’t in the name.
Limits in every response
Each response carries X-RateLimit-Limit/-Remaining (per minute) and X-Quota-Limit/-Remaining/-Reset (calls per month) and X-Rows-Limit/-Remaining (rows per month), so your client can pace itself. Every row returned counts once: a price on /prices, a retailer offer on /products, a point on /history. Use changed_since to sync only what moved. A 429 also includes Retry-After.
Responses
Every endpoint returns JSON with a data payload and a meta block. The meta always tells you how fresh the data is (updated_at) and where your quota stands.
{
"data": [ { "retailer": "shoprite", "barcode": "6001030002075",
"name": "...", "price": 21.99, "in_stock": true,
"image_url": "https://.../product.jpg" } ],
"meta": { "count": 50, "total": 1746, "has_more": true,
"next_offset": 50, "updated_at": "2026-07-22T06:18:00Z",
"quota": 50000, "used": 12 }
}A full /prices row. Values here are illustrative:
{
"retailer": "woolworths",
"barcode": "6009000000000",
"retailer_sku": "6009000000000",
"name": "Full Cream Milk 2 L",
"brand": "Example",
"image_url": "https://.../product.jpg",
"price": 34.99,
"currency": "ZAR",
"unit_size": "2 L",
"in_stock": null,
"category": "Milk",
"department": "dairy-eggs",
"last_seen_at": "2026-11-10T08:40:05Z",
"price_changed_at": "2026-10-20T08:41:12Z",
"gtin": "6009000000000",
"product_url": "https://www.woolworths.co.za/prod/...",
"loyalty_price": null,
"was_price": 39.99,
"promotion": "Buy any 2 for R60",
"promotion_loyalty": false,
"size": { "quantity": 2, "unit": "l" },
"unit_price": { "value": 17.5, "per": "l" },
"price_basis": "online",
"on_promotion": true
}/products?barcode= returns one product with an offer per retailer (with barcodes= or search=, data is a list of these):
{
"data": {
"barcode": "6009000000000",
"name": "Full Cream Milk 2 L",
"brand": "Example",
"image_url": "https://.../product.jpg",
"category": "Milk",
"department": "dairy-eggs",
"offers": [
{ "retailer": "shoprite", "retailer_sku": "10000000EA", "price": 33.99,
"currency": "ZAR", "in_stock": true, "unit_size": null,
"size": { "quantity": 2, "unit": "l" },
"unit_price": { "value": 17, "per": "l" },
"last_seen_at": "2026-11-13T08:09:40Z",
"product_url": "https://www.shoprite.co.za/product/...",
"loyalty_price": null, "price_basis": "online",
"was_price": 36.99, "on_promotion": true,
"promotion": null, "promotion_loyalty": null }
],
"min_price": 33.99,
"max_price": 33.99,
"cheapest_retailer": "shoprite",
"retailer_count": 1
},
"meta": { "count": 1, "updated_at": "2026-11-13T08:09:40Z",
"max_age_days": 30, "quota": 50000, "used": 13 }
}401No API key sent.
403Key is invalid or inactive. The message says what looks wrong, e.g. a key that was copied only in part.
429Monthly quota or per-minute rate limit reached (a Retry-After header tells you how long to wait).
Note: in_stock can be true, false, or null (unknown) where a retailer doesn’t report stock reliably. image_url links to the retailer’s own image, so it can be null or change over time. The free tier caps pages at 10 rows.
Pricing
Intro pricing while we’re in beta. Cancel anytime.
Free
Free
100 calls / month
1 000 rows / month
30 days of history · up to 20 requests / minute
Starter
R199/mo
50 000 calls / month
100 000 rows / month
90 days of history · up to 120 requests / minute
Business
R499/mo
500 000 calls / month
500 000 rows / month
Full price history · up to 600 requests / minute
Feed
R1 999/mo
1 000 000 calls / month
No row cap
Full-catalogue download, rebuilt after every update
or R19 990 / year
Full price history · up to 1200 requests / minute
Your keys
Prices are collected from public retailer sites and may lag real shelf prices. Use of the API is covered by the API Terms. Get in touch for bulk or enterprise access.