Loyalty HubLoyaltyHub
Developers

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 while price is a marked-down one; null otherwise.
  • promotion: the retailer’s wording of a multibuy offer, e.g. “Buy any 2 for R125” (Woolworths). price stays the price of one item; the offer isn’t applied to it.
  • promotion_loyalty: true when 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%”); null otherwise. Member multibuys (“Buy 2 For R28”) are in promotion only. price stays 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.
  • size and unit_price are null when 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.

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 }
}
401

No API key sent.

403

Key is invalid or inactive. The message says what looks wrong, e.g. a key that was copied only in part.

429

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