HOME/Articles/

Analyzing Seated's restaurants by reversing their API

Article Outline

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.

{% include image.html file="seated" alt="Seated screenshot" footnote="Screenshot from Seated app of a block in the LES in NYC" style="max-width:80%;"%}

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.

{% include image.html file="seated-api" alt="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.

<style> .rwd-table { margin: 1em 0; min-width: 300px; } .rwd-table tr { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; border: 1px solid black; } .rwd-table th { display: none; } .rwd-table td { display: block; } .rwd-table td:first-child { padding-top: .5em; } .rwd-table td:last-child { padding-bottom: .5em; } .rwd-table td:before { content: attr(data-th) ": "; font-weight: bold; width: 6.5em; display: inline-block; } @media (min-width: 480px) { .rwd-table td:before { display: none; } } .rwd-table th, .rwd-table td { text-align: left; } @media (min-width: 480px) { .rwd-table th, .rwd-table td { display: table-cell; padding: .25em .5em; } .rwd-table th:first-child, .rwd-table td:first-child { padding-left: 0; } .rwd-table th:last-child, .rwd-table td:last-child { padding-right: 0; } } .rwd-table { overflow: hidden; width: auto; margin: 4px auto; border-collapse: collapse; } .rwd-table tr { border-color: #46637f; } .rwd-table th, .rwd-table td { margin: .1em .5em; } .rwd-table th, .rwd-table td:before { font-weight: bold; } @media (min-width: 480px) { .rwd-table th, .rwd-table td { padding: .5em !important; } } </style>

{% include image.html file="seated-hist" alt="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
{:.rwd-table}

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.

{% include image.html file="seated-map" alt="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)

{% include image.html file="seated-downtown-head" alt="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?

{% include image.html file="seated-rating-vs-reward" alt="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.

{% include image.html file="seated-rating-stats" alt="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.

{% include image.html file="seated-price-vs-reward" alt="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 <b>jonluca1</b> to get $15 bonus on your first meal.