If you order groceries in India and they arrive in 10 minutes, there’s a dark store within 2–3 kilometres of your house. These are small, windowless warehouses — no walk-in customers, no signage, just pickers and riders. Blinkit, Zepto, and Swiggy Instamart collectively operate thousands of them across the country. I wanted the coordinates of every single one. Why? Partly curiosity about the infrastructure density of India’s Q-Commerce boom, partly a practical engineering challenge I wanted to document. This post is a breakdown of the full scraping stack — the APIs I reverse-engineered, the defenses I had to work around, and the physical hacks I used when software alone wasn’t enough. If you’re into scraping, reverse engineering, or mobile API analysis, this is for you. There’s No Store Directory These platforms don’t expose a public store list. There’s no /api/stores endpoint returning a clean JSON array. Their serviceability APIs are designed around a single question: "Is this GPS coordinate serviceable right now?" /api/stores "Is this GPS coordinate serviceable right now?" Send a lat/lng, get back whether a store covers you — and if so, which one. That serviceability check is the primitive everything else was built on top of. The Foundation: Blanket the Country, Filter the Noise Before writing a single worker, I had to solve a geographic scope problem. India is large. A 1.5–2km coordinate grid over India’s GeoJSON boundary produces roughly 1.4 — 3.3 million points (varies based on grid step) per platform. Most of them sit over mountains, forests, desert, or ocean — places with zero dark stores. 1.4 — 3.3 million points (varies based on grid step) The fix was a population density pre-filter using WorldPop — a freely available raster dataset encoding per-km² population estimates. I loaded India’s WorldPop raster (ind_pd_2020_1km.tif) and dropped any grid coordinate below a population threshold before the scraper ever started. population density pre-filter WorldPop WorldPop ind_pd_2020_1km.tif This single vectorized filter — a boolean mask over a NumPy array — collapsed 1.4 million potential API calls per platform down to a few hundred thousand, concentrated entirely in urban and peri-urban clusters where dark stores actually operate. How Each Platform Defended Itself The three platforms had meaningfully different defence profiles, which shaped the approach for each one. Blinkit was the hardest. HTTP 429 rate limits were frequent, and standard Python HTTP clients were immediately flagged by TLS/JA3 fingerprinting — Cloudflare detecting a non-browser TLS handshake before requests even reached the application layer. The fix was curl_cffi, which impersonates Chrome 110’s TLS footprint at the socket level (impersonate=”chrome110"). One useful discovery mid-project: Indian datacenter IPs worked fine against Blinkit. I moved to a Bright Data datacenter proxy pool for the bulk of the country sweep, which was far faster than mobile IP rotation. Blinkit Swiggy Instamart was more unpredictable. Beyond standard 400s range, Swiggy would silently circuit-break — returning 200 OK with either a completely empty body or placeholder-looking data that wasn’t real. Swiggy Instamart Concurrency had to stay low (10–20 workers) to stay under the WAF threshold. I switched to datacenter proxies with higher concurrency here as well once the data quality looked solid. Zepto was the most forgiving by far. A single IP could sustain tens of thousands of requests before triggering a rate limit. The discovery phase ran fast. Zepto One thing consistent across all three: the vast majority of scraping used unauthenticated API calls — the same endpoints the app uses before a user logs in. Authenticated requests only became necessary for Zepto’s deep data extraction phase. Platform 1: Blinkit — Converging 50 Metres Blinkit’s layout feed endpoint takes GPS coordinates and app identity as custom HTTP headers, and returns the full localized app homepage as a JSON widget tree. POST https://api2.grofers.com/v1/layout/feed app_client: consumer_android lat: 28.6139 lon: 77.2090 auth_key: 45bff2b1437ff764d5e5b9b292f9771428e18fc4XXXXXXXXXXXXXXXXX battery-level: EXCELLENT POST https://api2.grofers.com/v1/layout/feed app_client: consumer_android lat: 28.6139 lon: 77.2090 auth_key: 45bff2b1437ff764d5e5b9b292f9771428e18fc4XXXXXXXXXXXXXXXXX battery-level: EXCELLENT Buried inside the response: merchant_info.id (the store ID) and promise_time_state.DistanceInMeter — the exact road distance in metres from the spoofed coordinate to the nearest dark store. merchant_info.id promise_time_state.DistanceInMeter That distance field became the engine of the convergence algorithm. The Auto-Convergence Engine A 1.5km grid sweep confirms a store exists and gives a rough location. Getting to 50-metre accuracy required a second phase.For every discovered store, I took the last known shortest distance returned by the API and generated an 11×11 micro-grid — 121 new coordinate points arranged within that radius. I re-submitted this micro-grid, took the new shortest distance, and generated another, tighter micro-grid. Each generation shrinks the search area. After a few iterations, the reported distance converges on near-zero, pinpointing the store’s physical location. One complication: Blinkit stores occasionally go offline due to surge demand, returning messages like “Due to excess demand, please come back at 6:00 am.” Instead of dropping these, I kept them in a retry queue and waited them out before running the convergence pass. One complication “Due to excess demand, please come back at 6:00 am.” Platform 2: Zepto — The Store That Hands You Its Own Boundary Zepto was interesting for a different reason. The discovery phase was easy. The real surprise was what Zepto returned once I hit the right authenticated endpoint. Phase 1: Discovery The unauthenticated serviceability endpoint was simple. Send a coordinate, get back a storeId if Zepto covers it. I swept the population-filtered grid at 2km intervals, collecting IDs. Rate limits were rarely a concern at this stage. storeId The Authenticated Data Pull Store coordinates, names, and geofences weren’t available from the serviceability endpoint. That data lived in a separate, authenticated internal page endpoint — the app’s homepage payload for a logged-in session. This required spoofing an Android client (could have done with a web client too, which I realized later) with a valid Bearer token (obtained via a logged-in Zepto web/android session). Something like: POST /lms/api/v2/get_page HTTP/1.1 Host: api.zepto.com Content-Type: application/json Authorization: Bearer eyJhbGc........................ app_version: 26.3.1 User-Agent: okhttp/4.12.0 tenant: ZEPTO platform: android Connection: keep-alive { "page_size": 5, "latitude": 19.0760, "longitude": 72.8777, "page_type": "HOME", "version": "v2", "show_new_eta_banner": true, "cartInfo": { "removedCampaigns": [], "availedCampaignProducts": [], "cartProducts": [], "zeptoPass": { "isPassAdded": false, "isBxGyUserOpted": false } }, "multi_tab_version": "V2" } POST /lms/api/v2/get_page HTTP/1.1 Host: api.zepto.com Content-Type: application/json Authorization: Bearer eyJhbGc........................ app_version: 26.3.1 User-Agent: okhttp/4.12.0 tenant: ZEPTO platform: android Connection: keep-alive { "page_size": 5, "latitude": 19.0760, "longitude": 72.8777, "page_type": "HOME", "version": "v2", "show_new_eta_banner": true, "cartInfo": { "removedCampaigns": [], "availedCampaignProducts": [], "cartProducts": [], "zeptoPass": { "isPassAdded": false, "isBxGyUserOpted": false } }, "multi_tab_version": "V2" } The response is a large widget tree representing the entire app’s home screen. I deployed recursive JSON traversal hunting for storeDetailsResponse inside it. When found, Zepto returned the store’s real name, precise coordinates, and most notably — servicableGeofence storeDetailsResponse servicableGeofence (Yes, Zepto misspelled it — servicableGeofence is the actual field name in the response.) (Yes, Zepto misspelled it — servicableGeofence is the actual field name in the response.) (Yes, Zepto misspelled it — servicableGeofence is the actual field name in the response.) "storeDetailsResponse": { "city": { "country": "India", "name": "Delhi", "state": "Delhi" }, "cityId": "c5b3d670-f20e-4cae-a6b7-42e17b8fb08d", "closeTime": "20:30:00", "estimatedLaunchDate": "2024-12-11T01:30:00Z", "id": "3d03e7a7-4ded-458a-9349-2978cd333ef0", "initiateSdkNewFlow": true, "isActive": true, "isFullNightDeliveryEnabled": false, "isLive": true, "isOnline": true, "issueAtStore": false, "latitude": 28.641493, "longitude": 77.234596, "name": "DEL-Connaught Place", "openTime": "00:30:00", "phase": "LIVE", "raining": false, "servicableGeofence": [ ["28.6546246", "77.2365741"], ["28.6560326", "77.2366875"], ["28.6561437", "77.2344435"], ["28.6561287", "77.233521"], ... ] } "storeDetailsResponse": { "city": { "country": "India", "name": "Delhi", "state": "Delhi" }, "cityId": "c5b3d670-f20e-4cae-a6b7-42e17b8fb08d", "closeTime": "20:30:00", "estimatedLaunchDate": "2024-12-11T01:30:00Z", "id": "3d03e7a7-4ded-458a-9349-2978cd333ef0", "initiateSdkNewFlow": true, "isActive": true, "isFullNightDeliveryEnabled": false, "isLive": true, "isOnline": true, "issueAtStore": false, "latitude": 28.641493, "longitude": 77.234596, "name": "DEL-Connaught Place", "openTime": "00:30:00", "phase": "LIVE", "raining": false, "servicableGeofence": [ ["28.6546246", "77.2365741"], ["28.6560326", "77.2366875"], ["28.6561437", "77.2344435"], ["28.6561287", "77.233521"], ... ] } And the store’s real name, Precise latitude and longitude, servicableGeofence — a polygon of GPS coordinates outlining the store’s exact delivery boundary And the store’s real name, Precise latitude and longitude, servicableGeofence — a polygon of GPS coordinates outlining the store’s exact delivery boundary When a servicableGeofence like this is plotted on a map, it looks like this: servicableGeofence Platform 3: Swiggy Instamart — Working With the Cart API Phase 1: Pod Discovery Instamart stores are called “pods” internally. The select-location home API reveals which pod services a given coordinate and returns its podId. Sweeping the population-filtered grid gave me pod coverage across the country. select-location podId Getting the Store Data To retrieve deep store metadata — operational status, internal store info — I hit Instamart’s cart API with a guest cart payload targeting a specific pod. To construct a cart payload the backend would actually process, I needed a spinId alongside the podId. The spinId is an internal product identifier surfaced during the Phase 1 discovery sweep — I don't know what it maps to semantically, but it was the minimum required field to make the cart payload structurally valid. Without it, the backend rejects the request outright. spinId podId spinId POST /api/instamart/checkout/v2/cart?pageType=INSTAMART_CART HTTP/1.1 Host: www.swiggy.com Content-Type: application/json Cookie: lat=19.076000; lng=72.877700 { "data": { "items": [{ "productId": "", "quantity": 1, "tradeFreebie": true, "spin": "84729103", "itemId": "T", "meta": { "type": "structure", "storeId": 10014, "freebie": false, "isGiftBag": false }, "serviceLine": "INSTAMART" }], "cartMetaData": { "deliveryType": "INSTANT", "primaryStoreId": 10014, "storeIds": [10014] }, "cartType": "INSTAMART" }, "source": "userInitiated" } POST /api/instamart/checkout/v2/cart?pageType=INSTAMART_CART HTTP/1.1 Host: www.swiggy.com Content-Type: application/json Cookie: lat=19.076000; lng=72.877700 { "data": { "items": [{ "productId": "", "quantity": 1, "tradeFreebie": true, "spin": "84729103", "itemId": "T", "meta": { "type": "structure", "storeId": 10014, "freebie": false, "isGiftBag": false }, "serviceLine": "INSTAMART" }], "cartMetaData": { "deliveryType": "INSTANT", "primaryStoreId": 10014, "storeIds": [10014] }, "cartType": "INSTAMART" }, "source": "userInitiated" } The geographic cookies are essential. The lat/lng values have to correspond to the pod’s actual service zone — send the wrong coordinates and the response returns nothing useful. With a valid payload and matching location cookies, Instamart’s backend returned a storesInfo array containing the store’s coordinates and operational status. storesInfo The Sentinel Loop Quick-commerce stores go offline transiently — heavy rain, inventory exhaustion, surge demand. When a pod was offline, Swiggy’s API would silently route me to a different nearby store instead. If that was a store I hadn’t seen before, I stored it — but kept the original offline pod in the queue for retry. The sentinel loop re-attempted every offline pod every few minutes until it came back online and returned its own data directly. Without this, a significant chunk of the network would have been missing from the final dataset. Initial Physical Setup Initial Physical Setup In the early stages, I wasn’t sure if any of this was feasible. Before spending money on proxies to prove a concept, I went with the cheapest possible IP rotation: a physical Android phone sitting next to the laptop, tethered as a mobile hotspot. When a 429 hit, a Windows alert popped up, I toggled Airplane Mode manually to get a fresh carrier IP, and hit Enter to resume all workers. Crude, but free — and it was enough to prove the approach worked. Once validated, I automated this with MacroDroid: a webhook on the Android device that toggled Airplane Mode on/off command. The Python script fired the webhook, waited 18 seconds for the cellular radio to cycle, polled api.ipify.org until a new IP was confirmed, then resumed all async workers. MacroDroid api.ipify.org For the production sweeps on Blinkit and Instamart, I moved to a Bright Data datacenter proxy pool — faster, scalable, and datacenter IPs weren’t being filtered by either platform. What the Final Dataset Looks Like After running all three scrapers to completion: Every active Blinkit dark store in India, coordinates converged to within 50 metres Every active Zepto dark store, with exact delivery geofence polygons Every active Swiggy Instamart pod, with coordinates and operational status Every active Blinkit dark store in India, coordinates converged to within 50 metres Every active Blinkit dark store in India, coordinates converged to within 50 metres Every active Zepto dark store, with exact delivery geofence polygons Every active Zepto dark store, with exact delivery geofence polygons Every active Swiggy Instamart pod, with coordinates and operational status Every active Swiggy Instamart pod, with coordinates and operational status Takeaways Pre-filter before you scale: Example: 1.4–3.3 million coordinate points per platform is too many. WorldPop + rasterio collapses that to a manageable working set before a single API call is made. A 200 is not a success: Swiggy's WAF returns 200 OK with an empty or placeholder body. Always validate the response body explicitly, not just the status code. Validate cheaply before you spend: Mobile hotspot IP rotation is free and effective for proving an approach works. Switch to proxies once you know the data is there. Pre-filter before you scale: Example: 1.4–3.3 million coordinate points per platform is too many. WorldPop + rasterio collapses that to a manageable working set before a single API call is made. Pre-filter before you scale: E A 200 is not a success: Swiggy's WAF returns 200 OK with an empty or placeholder body. Always validate the response body explicitly, not just the status code. A 200 is not a success: 200 OK Validate cheaply before you spend: Mobile hotspot IP rotation is free and effective for proving an approach works. Switch to proxies once you know the data is there. Validate cheaply before you spend: