Muwazin Data API
Saudi grocery prices as JSON: the catalog behind Muwazin, 96,000+ products with a current price at 21 chains, matched across retailers and priced at 484 branches, with promotions, stock and history, refreshed every night. Base URL https://api.muwazin.me/v1.
Quickstart: five calls
- Get a key. Request access and we email you an Evaluation key, usually within two business days. Evaluation keys are for a proof of concept: 500 units and 200 products of browse for the life of the key (they run for 14 days), 30 requests a minute, 30 days of history. Developer is SAR 299 a month, Pro SAR 899 and Business SAR 2,499; Enterprise is priced on request (plans). Once you have a key, the console shows its usage and lets you rotate it.
- Check the key works.
curl https://api.muwazin.me/v1/me \
-H "Authorization: Bearer mw_live_YOUR_KEY"
- Look a product up by barcode. Zero-padding variants are handled for you.
curl https://api.muwazin.me/v1/barcodes/6281007001081 \
-H "Authorization: Bearer mw_live_YOUR_KEY"
- Search in Arabic or English. Filters:
category,retailer,brand,inStock; sort byprice_asc,price_desc,newest.
curl "https://api.muwazin.me/v1/products?q=حليب%20المراعي&inStock=true&sort=price_asc&limit=20" \
-H "Authorization: Bearer mw_live_YOUR_KEY"
- See what moved, last night or week over week, and chart a product's history.
/v1/retailerslists the chains with their price source and branch count,/v1/categoriesthe aisles with product counts, and/v1/storesthe store locations. Products without an aisle are counted asuncategorized: theircategoryisnull, andcategory=uncategorizedfinds them.
curl "https://api.muwazin.me/v1/price-changes?direction=drop&minPct=10&limit=50" \
-H "Authorization: Bearer mw_live_YOUR_KEY"
curl "https://api.muwazin.me/v1/weekly-changes?direction=drop&minPct=10&limit=50" \
-H "Authorization: Bearer mw_live_YOUR_KEY"
curl "https://api.muwazin.me/v1/products/PRODUCT_ID/history?from=2026-06-01&retailer=panda" \
-H "Authorization: Bearer mw_live_YOUR_KEY"
A product, as returned
{
"id": "m3DnxmCZCZ9j4uojubqe",
"barcode": "6281007001081",
"name": { "ar": "حليب المراعي كامل الدسم 2 لتر", "en": "Almarai Full Fat Milk 2L" },
"brand": "Almarai",
"size": { "weight": "2L", "unit": "L" },
"priceBasis": "item",
"category": "dairy-eggs",
"image": "https://…",
"matchConfidence": 0.98,
"cheapest": { "retailer": "panda", "price": 8.95, "priceBasis": "item" },
"prices": [
{ "retailer": "panda", "retailerName": { "ar": "بنده", "en": "Panda" },
"price": 8.95, "priceBasis": "item", "currency": "SAR", "inStock": true, "stale": false,
"scrapedAt": "2026-09-17T02:14:00Z", "url": "https://panda.com.sa/…" }
],
"updatedAt": "2026-09-17T02:40:11Z"
}
Every field is documented in the API reference, where you can paste your key and try the calls from the page.
Branch prices
Where a retailer prices by branch, GET /v1/products/{id}/branches returns the product's price at every branch we collect (1 unit): the branch's name, city and location, the price and the branch's "was" price when it is on sale there, stock state and quantity where the retailer publishes them, its promotion and purchase limit, and stale for a branch not swept in the last eight days. A branch with no complete sweep for three days reports inStock: false. Filter with city= (any common spelling), retailer=, inStock=, or near=lat,lng&radiusKm= for the nearest real stores. store.kind is store, centre (a Lulu store whose location we do not hold: no distance) or area (a Carrefour catalogue area, no distance). Every product also carries branches.total / branches.inStock.
curl "https://api.muwazin.me/v1/products/PRODUCT_ID/branches?city=Riyadh&inStock=true" \
-H "Authorization: Bearer mw_live_YOUR_KEY"
{ "productId": "PRODUCT_ID", "priceBasis": "item", "city": "Riyadh",
"items": [
{ "retailer": "danube", "retailerName": { "ar": "الدانوب", "en": "Danube" },
"store": { "code": "202", "name": { "ar": "…", "en": "Danube Al Rawdah" }, "city": "Riyadh",
"lat": 24.71, "lng": 46.67, "distanceKm": null, "kind": "store" },
"price": 15.99, "priceBasis": "item", "originalPrice": 22.95, "currency": "SAR", "inStock": true,
"stockState": "in", "stockQty": null, "promo": null, "limits": null, "flags": { "on_sale": true },
"updatedAt": "2026-09-25T02:40:11Z", "branchLastSeen": "2026-09-25T02:40:11Z", "stale": false }
],
"nextCursor": null, "dataAsOf": "2026-09-25T02:40:11Z" }
Intelligence (Business and above)
Two nightly-computed market reports are available to plans with intelligence access (5 units per call):
GET /v1/intel/benchmark— retailer competitiveness on the products two or more retailers stock: cheapest-price wins, win % and average/median premium per retailer.GET /v1/intel/volatility?retailer=&category=— the most-changed prices of the last 30 days per product×retailer, with change counts, price ranges and a volatility score.
Authentication
Send your key on every request as Authorization: Bearer mw_live_…. The X-API-Key header is accepted too. Keys are secrets: keep them server-side, never in a mobile app or browser bundle. If a key leaks, rotate it in the console: the old secret stops working immediately, and the key keeps its plan, its expiry date and the units it has used.
Only /v1/health, these pages and the OpenAPI file are public.
Accepting the terms
A new key can call /v1/me and nothing else until the account behind it has accepted the terms of use. Every data endpoint answers 403 terms_required before that. Accept them once, either by signing in to the console or with the key itself:
curl -X POST https://api.muwazin.me/v1/terms \
-H "Authorization: Bearer mw_live_…" \
-H "Content-Type: application/json" \
-d '{"version": "<the version on the terms page>", "accept": true}'
The version is printed on the terms page, in the 403 body (which also carries this exact call under terms.accept) and in /v1/me. Sending it means you read that version. Accepting costs no units and covers every key on your account; when the terms change, the new version has to be accepted again.
Prices, staleness and units
Where a price comes from
Every price carries the retailer it was collected from, the collection time scrapedAt (the last complete nightly sweep that confirmed it), the retailer's product page url, and a stale flag. Prices are collected once a night from each retailer's own website or app, or from the delivery storefront it sells through; /v1/retailers says which (priceSource) and how many branches each chain has. A price is stale when its retailer has not been collected successfully for two nights; stale and out-of-stock prices are excluded from cheapest. After three days without a complete collection, the retailer's prices also report inStock: false, as they do in the app. An item that a retailer's full nightly collection stops listing reports inStock: false from the second night it is missing (for the few retailers whose website shows part of their range each night, after 14 days). The X-Data-As-Of response header tells you when the last pipeline night finished.
Per item or per kilogram
Every price says what it buys in its own priceBasis. item is the price of one item or pack, as the retailer lists it. kg is the price of one kilogram of a product sold by weight (loose produce, meat and fish, deli cheese and olives, where the shopper picks the weight).
A product has a priceBasis too: kg when it is sold by weight. Such a product has no pack size, so its size is empty. Most of its prices are per kilogram, but a retailer that sells the same product as a pack keeps its pack price, marked item. Compare two prices only when their priceBasis is the same. A deli item sold in fixed portions can come as item throughout, at the portion's price, so read the field rather than inferring it from the category.
The field is on each entry of prices, on cheapest, on every row of /products/{id}/branches, /price-changes and /weekly-changes. /history returns priceBasisByRetailer: a retailer's whole series is in the basis of its current price.
Matching
Products are matched across retailers by barcode where retailers publish one (Carrefour and the chains sold through HungerStation do not), otherwise by name, size and brand with automated gates and manual review; a product's own barcode is checked and repaired from its listings. matchConfidence is 0–1; treat prices under a low-confidence product with care and use the url to verify. Branch prices join a product through the same listings, so a branch row always belongs to the chain's listing you see under prices.
When two products turn out to be one
Now and then two product records are found to be the same item and are merged. The id that stays keeps every price; the other id keeps working: /products/{id}, /history and /branches answer with the product it went into, under that product's id, and add mergedFrom with the id you asked for. Store the new id when you see it. A merged id is never returned by search, browse or a barcode lookup.
Units
Plans are metered in units, not calls. Most calls cost 1 unit, /products/{id}/branches included; /products (search or browse), /history, /price-changes and /weekly-changes cost 2; the /intel/* reports cost 5. Failed calls and /me cost nothing. Three headers come back on every response: X-RateLimit-Limit, X-RateLimit-Remaining and X-Quota-Remaining. The usage page in the console plots what you have spent, day by day.
Pagination
List endpoints return nextCursor. Pass it back as ?cursor= to get the next page; there is no offset paging. Search (q=) returns up to 50 per page and stops at 500 results per query, so narrow with filters. Browse (no q=) returns up to 200 per page. /stores pages the same way.
The browse allowance
Browse is also metered in products, separately from units: 200 on Evaluation, 10,000 a month on Developer, the whole catalog on Pro and above. The allowance covers the same period as your units, either the calendar month or the whole life of a key that expires, and changing the filters does not start a new one. When it runs out the last page carries "capped": true and further browse calls answer 403 browse_capped. Search, barcode and product lookups are not affected, and /me reports what is left.
Errors
Errors are JSON with a stable code:
{ "error": { "code": "rate_limited", "message": "Rate limit is 30 requests/minute for this key." } }
| HTTP | code | What to do |
|---|---|---|
| 401 | missing_key / invalid_key | Send a valid key in the Authorization header. |
| 403 | key_paused | We have paused the key. Contact us at hello@muwazin.me, or wait for our email with further information. A paused key cannot be rotated or revoked in the console. |
| 403 | key_revoked / key_expired | Check the key in the console, or ask us for a new one. |
| 403 | terms_required | The account has not accepted the current terms. POST /v1/terms with the version and accept: true (see Accepting the terms), or accept in the console. |
| 409 | terms_outdated | You sent an older terms version. Read the current one (the 409 names it) and send that. |
| 403 | plan_required / browse_capped | The browse allowance is spent, or the endpoint needs a bigger plan (the /intel/* reports need Business or above). |
| 429 | rate_limited / search_rate_limited | Back off for the Retry-After seconds. |
| 429 | quota_exceeded | Monthly units used up; resets on the 1st. Request a bigger plan. |
| 400 | invalid_cursor / invalid_barcode / invalid_range / unknown_city / invalid_near | Fix the request: send nextCursor back exactly as you received it, from and to are dates from 1900-01-01 to 2100-01-01, city= takes any common spelling, and near= is lat,lng in degrees. |
| 400 | invalid_request | The path or a parameter contains a NUL character (%00). Remove it. |
| 422 | invalid_request | A parameter has the wrong type or is out of range. message names it, and fields lists each one (example below). |
| 404 | not_found | No such product, barcode or endpoint. |
| 405 | method_not_allowed | The endpoint takes another HTTP method; the Allow header names it. |
| 503 | search_unavailable | Search backend hiccup; retry shortly. |
A 422 names each wrong parameter in message, and again one by one in fields (in is path, query or body):
{ "error": { "code": "invalid_request",
"message": "`limit`: Input should be less than or equal to 200",
"fields": [ { "in": "query", "name": "limit", "message": "Input should be less than or equal to 200" } ] } }
Plans and limits
Developer is SAR 299 a month, Pro SAR 899 and Business SAR 2,499. You pay in advance on the console's plan page, one or twelve months at a time, and nothing renews by itself: the key runs until the months you bought end, then answers key_expired until you buy more. A pass for a higher plan starts at once, and the time left on your current plan is added at its value. Enterprise is quoted on request: reply to the email that carried your key, or use the request form.
| Evaluation | Developer | Pro | Business | |
|---|---|---|---|---|
| Price | Free | SAR 299 / month | SAR 899 / month | SAR 2,499 / month |
| Units | 500 per key | 50,000 / month | 250,000 / month | 1,000,000 / month |
| Requests per minute | 30 | 60 | 150 | 300 |
| Searches per minute | 10, keyword | 20, keyword | 60, hybrid | 120, hybrid |
| Price history | 30 days | 90 days | full | full |
| Browse allowance | 200 products per key | 10,000 products / month | full catalog | full catalog |
| Exports (CSV, by arrangement) | no | no | on request | on request |
Benchmark & volatility reports (/intel/*) | no | no | no | yes |
| Attribution when displayed | required | required | required | optional |
| Support | docs | email, 2 business days | priority | |
| Key expiry | 14 days | when your pass ends | when your pass ends | when your pass ends |
Enterprise: custom volumes, SLA, contract, annual licences for institutions. Tell us what you need.
Every plan is used under the terms of use. In short: an evaluation key is for evaluation and carries no commercial licence, and the data may not be resold or republished without our written permission. A key returns no data until they are accepted, in the console or with POST /v1/terms.
quota_exceeded until the next month, or, for an evaluation key, until we issue you a new one.