Analyzing Seated’s restaurants by reversing their API
Last month I wrote about writing a bot to automatically get reservations for OpenTable. In that same vein, Seated is an app that offers a certain (sizable) percentage off your bill for certain restaurants in your area.
Screenshot from Seated app of a block in the LES in NYC
The rebates actually get pretty substantial - up to 50% in some cases. Half off dinner in NYC is a pretty enticing proposition (although most of the restaurants seem to be in the 10 - 20% range).
It’s a pretty interesting app - I’m not entirely sure what their business model is (as I can’t image that a known low-margin industry like restaurants would be able to rebate 50% of the total value of a bill), but it does work and their rebates are easy to claim.
I wanted to see if I could grab all their restaurants and visualize the stats on them.
Finding their API
I used the same methodology as in the previous article - luckily, the app didn’t do any form of TLS pinning, and I was able to quickly reverse their routes.
I then converted it to Python using a handy curl converter.
import requests
headers = {
'Host': 'api.seatedapp.io',
'Content-Type': 'application/json',
'Flavor': 'native_app_v1',
'Accept': '*/*',
'Authorization': f"Bearer {os.environ.get('BEARER_TOKEN')}",
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate',
'Platform': 'ios',
'User-Agent': 'Seated/1075 CFNetwork/1327.0.4 Darwin/21.2.0',
'Device': '?unrecognized?',
'Build': '1075',
}
params = (
('city', '2'),
('isRemoveFutureWalkInVariant', 'false'),
('latitude', '40.71'),
('longitude', '-73.98'),
('mapLatitude', '40.71'),
('mapLongitude', '-73.99'),
('maxSeats', '2'),
('minSeats', '2'),
('page', '1'),
('size', '50'),
('slotForDate', '2022-01-02T18:30:00.000Z'),
)
response = requests.get('https://api.seatedapp.io/v2/search/map/dinein', headers=headers, params=params, verify=False)
I then adapted this to save all the restaurants for later processing, and played around with the size of the response (it seemed to error out on values above 400).
import requests
import json
import os
headers = {
'Host': 'api.seatedapp.io',
'Content-Type': 'application/json',
'Flavor': 'native_app_v1',
'Accept': '*/*',
'Authorization': f"Bearer {os.environ.get('BEARER_TOKEN')}",
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate',
'Platform': 'ios',
'User-Agent': 'Seated/1075 CFNetwork/1327.0.4 Darwin/21.2.0',
'Device': '?unrecognized?',
'Build': '1075',
}
def get_params_from_page(page=1):
return (
('city', '2'),
('isRemoveFutureWalkInVariant', 'false'),
('latitude', '40.71'),
('longitude', '-73.98'),
('mapLatitude', '40.71'),
('mapLongitude', '-73.99'),
('maxSeats', '2'),
('minSeats', '2'),
('page', str(page)),
('size', '400'),
('slotForDate', '2022-01-02T18:30:00.000Z'),
)
restaurants = []
page = 1
while True:
params = get_params_from_page(page)
response = requests.get('https://api.seatedapp.io/v2/search/map/dinein', headers=headers, params=params)
data = response.json()
if 'restaurants' in data:
restaurants += data['restaurants']
else:
print("Something went wrong")
if 'metaData' in data and not data['metaData']['hasMoreItems']:
break
print(f"Completed page {str(page)}")
page += 1
print(f"Found {len(restaurants)} restaurants")
with open('restaurants.json', 'w') as out:
out.write(json.dumps(restaurants))
out.close()
Each restaurant entry had the following shape:
{
"id": 6673,
"name": "Memphis Seoul",
"longitude": -73.9579086303711,
"latitude": 40.67172622680664,
"priceRating": 2,
"neighborhood": {
"id": 92,
"name": "Crown Heights ",
"branchNavigationLink": "https://seated.app.link/8kpY6fF558",
"cityId": 2
},
"image": {
"url": "https://storage.googleapis.com/voco_main_bucket/images/3382905/original/19b89f300df44f21.png"
},
"images": [
{
"url": "https://storage.googleapis.com/voco_main_bucket/images/3382911/original/df2174d0b63060e8.png"
}
],
"isSurging": true,
"yelpRating": 3.5,
"baseReward": 32,
"isWalkInOnly": true,
"yelpBusinessUrl": "https://www.yelp.com/biz/memphis-seoul-brooklyn",
"menuUrl": "https://www.getmemphisseoul.com/menus/",
"address": "569 Lincoln Pl, Brooklyn, NY 11238, USA",
"thoughts": "\"Our thoroughly unique menu is a tasty selection of Southern favorites fused with Korean ingredients and influences. Come for the savory pulled pork and Korean BBQ Meatloaf, then stay for the scrumptious ",
"deliveryUrl": "https://direct.chownow.com/order/14302/locations/20081",
"pickupUrl": "https://direct.chownow.com/order/14302/locations/20081",
"deliveryReward": 25,
"pickupReward": 25,
"maxReward": 47,
"phoneNumbers": [
{
"phoneNumber": "3473492561",
"phonePrefix": "1"
}
],
"providerName": "ChowNow",
"orderProviderType": "ONLINE",
"isDeliverySurging": true,
"isPickupSurging": true,
"branchNavigationLink": "https://seated.app.link/tRSa5nQEy4",
"primaryCuisine": {
"id": 1,
"name": "American",
"branchNavigationLink": "https://seated.app.link/H0lPkpGUf4"
},
"maxPartySize": 12,
"isOpen": true,
"isDineIn": false,
"availableSeatingOption": "UNKNOWN",
"city": {
"id": 2,
"name": "New York City",
"nameLower": "new york city"
},
"delivery": true,
"pickup": true
}
Rewards
Once I had all the data, I wanted to see where most restaurants fell, in terms of their max reward distribution.
Rebate % # Restaurants (0, 5] 1 (5, 10] 289 (10, 15] 547 (15, 20] 266 (20, 25] 264 (25, 30] 116 (30, 35] 47 (35, 40] 3 (40, 45] 3 (45, 50] 2
Most restaurants fall in the 10-15% back, and only a handful are above 35%. I expected a more normal distribution, and fewer restaurants offering 20%+, but it’s a pretty healthy range and they’re clustered in a relatively high return zone.
Visualizing locations
I also wanted to see where all their restaurants were - I didn’t necessarily want to travel to Yonkers for 30% off a pizza.
This worked very well - it was trivial to then cut the dataframe to only restaurants in downtown NYC:
les_df = geo_df[geo_df['latitude'].between(40.7,40.767645)]
les_df[['name',reward]].head(10)
Rating vs Reward
What’s interesting is that every restaurant also has a yelp rating. I wondered if the rating was correlated to the rebate size - were worse restaurants offering more (if it was even the restaurant’s choice on the max reward %?) due to low rewards?
And the answer is… kinda? There highest rewards show up at the 3 and 4 star ratings, but they also have the highest distribution of restaurants, so with more data points you’re more likely to have outliers.
The above shows the stats for the reward based on the yelp rating. If we assume that 1, 2 and 5 star ratings are low-volume outliers, we see a slight correlation, but not enough to draw any conclusions from.
Reward vs Price
Another interesting potential correlation is with the maximum rebate % and how expensive the restaurant is - you’d imagine that nicer restaurants would offer less back.
This is somewhat observed, although there are some 4 dollar sign restaurants that offer 30% back.
Code
The code for the querying and analysis can be found on GitHub, at this repo.
Joining Seated
If you end up signing up for Seated, you can use my referral code jonluca1 to get $15 bonus on your first meal.