JonLuca's Blog

02 Jan 2022

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.

Seated screenshot

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.

Seated API

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.

Histogram of number of restaurants at each discount bucket
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.

Map of all seated's restaurants

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)
Downtown restaurants with highest rebates

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?

Reward vs Yelp rating

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.

Yelp rating mean stats

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.

Reward vs Price

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.

JonLuca

JonLuca at 10:28

Follow @jonluca
Share on: