Getting the best deals on Uber Eats (Part 1)

Disclaimer: The sole purpose of this article is to educate how to extract structured data from websites, how to use APIs and how developers or power-users can leverage these in order to perform periodic checks and trigger notifications on certain conditions.

The reason for this article

Have you ever found yourself missing on the best deals of your favorite restaurant?! The purpose of this article is to understand how Uber Eats (webapp) works and reverse-engineer it, getting the structured data, and check if our favorite restaurant has the “Buy One, Get One Free” promotion going on. Finally, we want trigger this check at let’s say 12pm and 19pm and get email-notified 🔔 if there’s something special for us 🍔

NOTE: We’ll be using the Uber Eats for Portugal but all the logic below can be applied to other locations.

Reverse-engineering

So the first page we’re presented with when visiting Uber Eats is this, or something very similar:

Uber Eats Homepage for Portugal Location

If you have visited it before and entered your exact delivery location, you’ll notice that this is not the first page shown to you. This is because the website already setup some preference cookies to get you directly to the restaurants page every time you access Uber Eats. If this is the case, please open an incognito tab in order to be able to follow the article.

By now, you want to have your DevTools open and if you go to Application tab > Storage > Cookies > https://www.ubereats.com you would see something like below - these are preference/session cookies.

Cookie Name Cookie Value
marketing_vistor_id
uev2.gg true
uev2.id.session e9c52385….
uev2.ts.session 163364….
uev2.id.xp ….
uev2.mwebAppInstallFullScreenBannerClosed ….

Ok, so now go ahead and enter a location. We’ll use Marquês de Pombal Square. If you ever visited Lisbon you’ll probably recognize it.

Take a close look on the Network Tab and the first thing that pops to the eye are the XHR calls. These are usually calls to API to get or set data and by inspecting the response body of them, the most interesting would probably be this: https://www.ubereats.com/api/getFeedV1?localeCode=pt

Uber Eats Feed API body response

This is the source that will populate the page with the current available restaurants. feedItems is the most interesting property. If we dig deeper into each of these feed items, we’ll find one property called signposts. Sometimes it’s populated with null and sometimes you get something like this:

{
  "signposts": [
    {
      "backgroundColor": {
        "color": "#05A357"
      },
      "text": "Compre 1, receba 1 grátis",
      "textColor": {
        "color": "#FFFFFF"
      }
    }
  ]
}

If you haven’t understood yet, the text “Compre 1, receba 1 grátis” in portuguese means “Buy 1, Get 1 free”. Bingo ! We have found the specific data-point we were looking for. Now let’s jump into the code.

The code

In order to automate the process, we’ll need to write some code. We’ll use Python, but we could use Node.js for the case. We won’t go into the specific syntax of the language but in pseudo-code what we want to do is something like this:

Set restaurants_with_deals: []
Call the API endpoint, check if http status code is 200
Iterate the property feedItems
    If "The best pizza restaurant" in item.store.title AND item.store.signposts contains "Compre 1, receba 1 grátis"
        push item.store.title into restaurants_with_deals

If restaurants_with_deals length greater than zero
    Send email notification with restaurants_with_deals

Calling the API

One of the best ways to replicate the API calling is to use cURL. It’s a pretty basic command-line tool, yet very powerful to debug and reproduce HTTP requests. It even comes installed on Windows.

So let’s try calling it with the same JSON payload. One trick you can use is copying the XHR request using DevTools. Just right-click over the XHR request and do Copy>Copy as cURL. You’ll see this giant blob of information like this.

curl "https://www.ubereats.com/api/getFeedV1?localeCode=pt" ^
  -H "x-csrf-token: x" ^
  -H "user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" ^
  -H "content-type: application/json" ^
  -H "accept: */*" ^
  -H "origin: https://www.ubereats.com" ^
  -H "sec-fetch-site: same-origin" ^
  -H "sec-fetch-mode: cors" ^
  -H "sec-fetch-dest: empty" ^
  -H "referer: https://www.ubereats.com/pt/feed?pl=JTdCJTIyYWRkcmVzcyUyMiUzQSUyMk1hcnF1JUMzJUFBcyUyMGRlJTIwUG9tYmFsJTIyJTJDJTIycmVmZXJlbmNlJTIyJTNBJTIyQ2hJSmVYRGZMWGN6R1EwUmo0clhYbzMtaUwwJTIyJTJDJTIycmVmZXJlbmNlVHlwZSUyMiUzQSUyMmdvb2dsZV9wbGFjZXMlMjIlMkMlMjJsYXRpdHVkZSUyMiUzQTM4LjcyNDk0MTQlMkMlMjJsb25naXR1ZGUlMjIlM0EtOS4xNTA5OTY4JTdE" ^
  -H "accept-language: pt-PT,pt;q=0.9" ^
  -H "cookie: uev2.id.xp=a28c52ba-e720-40c1-93b3-3e9d0ee1988a; dId=d9021049-d7af-4ad9-920a-bc84db56d133; uev2.id.session=5127e00a-7141-4f41-9bec-c0f70d829cd6; uev2.ts.session=1633648711936; uev2.loc=^%^7B^%^22address^%^22^%^3A^%^7B^%^22address1^%^22^%^3A^%^22Marqu^%^C3^%^AAs^%^20de^%^20Pombal^%^22^%^2C^%^22address2^%^22^%^3A^%^22Lisboa^%^22^%^2C^%^22aptOrSuite^%^22^%^3A^%^22^%^22^%^2C^%^22eaterFormattedAddress^%^22^%^3A^%^221250-160^%^20Lisboa^%^2C^%^20Portugal^%^22^%^2C^%^22subtitle^%^22^%^3A^%^22Lisboa^%^22^%^2C^%^22title^%^22^%^3A^%^22Marqu^%^C3^%^AAs^%^20de^%^20Pombal^%^22^%^2C^%^22uuid^%^22^%^3A^%^22^%^22^%^7D^%^2C^%^22latitude^%^22^%^3A38.7249414^%^2C^%^22longitude^%^22^%^3A-9.1509968^%^2C^%^22reference^%^22^%^3A^%^22ChIJeXDfLXczGQ0Rj4rXXo3-iL0^%^22^%^2C^%^22referenceType^%^22^%^3A^%^22google_places^%^22^%^2C^%^22type^%^22^%^3A^%^22google_places^%^22^%^2C^%^22source^%^22^%^3A^%^22rev_geo_reference^%^22^%^2C^%^22addressComponents^%^22^%^3A^%^7B^%^22countryCode^%^22^%^3A^%^22PT^%^22^%^2C^%^22firstLevelSubdivisionCode^%^22^%^3A^%^22Lisboa^%^22^%^2C^%^22city^%^22^%^3A^%^22Lisboa^%^22^%^2C^%^22postalCode^%^22^%^3A^%^221250-160^%^22^%^7D^%^2C^%^22originType^%^22^%^3A^%^22user_autocomplete^%^22^%^7D; marketing_vistor_id=78e72918-ff6c-4a37-bb64-be298be77a24; jwt-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MzM2NDg3MTIsImV4cCI6MTYzMzczNTExMn0.-sZDzc__dhesxypJkvNZ0wtHzquiJ7DVuyTHmn7Avew" ^
  --data-raw "^{^\^"cacheKey^\^":^\^"JTdCJTIyYWRkcmVzcyUyMiUzQSUyMk1hcnF1JUMzJUFBcyUyMGRlJTIwUG9tYmFsJTIyJTJDJTIycmVmZXJlbmNlJTIyJTNBJTIyQ2hJSmVYRGZMWGN6R1EwUmo0clhYbzMtaUwwJTIyJTJDJTIycmVmZXJlbmNlVHlwZSUyMiUzQSUyMmdvb2dsZV9wbGFjZXMlMjIlMkMlMjJsYXRpdHVkZSUyMiUzQTM4LjcyNDk0MTQlMkMlMjJsb25naXR1ZGUlMjIlM0EtOS4xNTA5OTY4JTdE/DELIVERY///0/0//JTVCJTVE/////^\^",^\^"feedSessionCount^\^":^{^\^"announcementCount^\^":0,^\^"announcementLabel^\^":^\^"^\^"^},^\^"showSearchNoAddress^\^":false,^\^"userQuery^\^":^\^"^\^",^\^"date^\^":^\^"^\^",^\^"startTime^\^":0,^\^"endTime^\^":0,^\^"carouselId^\^":^\^"^\^",^\^"sortAndFilters^\^":^[^],^\^"marketingFeedType^\^":^\^"^\^",^\^"billboardUuid^\^":^\^"^\^",^\^"feedProvider^\^":^\^"^\^",^\^"promotionUuid^\^":^\^"^\^",^\^"targetingStoreTag^\^":^\^"^\^",^\^"venueUuid^\^":^\^"^\^",^\^"favorites^\^":^\^"^\^",^\^"getFeedItemType^\^":^\^"DYNAMIC^\^"^}"
  --compressed

Copy-paste it into a .bat file or .sh depending on which OS you’re using. In our case it’s Linux, so we copied from Chrome with the Bash option. After executing the script, the output should be something similar to this.

{"status":"success","data":{"countdowns":[],"diningModes":[{"mode":"DELIVERY","title":"Entrega","isAvailable":true,"isSelected":true},{"mode":"PICKUP","title":"Recolha","isAvailable":true,"isSelected":false}],"sortAndFilters":[{"uuid":"1c7cf7ef-730f-431f-9072-26bc39f7c021","type":"sort","label":"Ordenar","maxPermitted":1,"minPermitted":1,"options":[{"uuid":"3c7cf7ef-730f-431f-9072-26bc39f7c022","value":"Recommended","isDefault":true,"label":"  Escolhido para si (predefinido)","iconUrl":"https://duyt4h9nfnj50.cloudfront.net/sort_and_filters/filter-ic-recommended-v2.png"},{"uuid":"4c7cf7ef-730f-431f-9072-26bc39f7c023","value":"Most popular","isDefault":false,"label":"  Mais populares","iconUrl":"https://duyt4h9nfnj50.cloudfront.net/sort_and_filters/filter-ic-mostpop.png"},{"uuid":"5991d63c-4d42-46bc-8301-ce224557e615","value":"Rating","isDefault":false,"label":"  Classificação","iconUrl":"http://duyt4h9nfnj50.cloudfront.net/sort_and_filters/rating_icn.png"},{"uuid":"5c7cf7ef-730f-431f-9072-26bc39f7c024","value":"Delivery time","isDefault":false,"label":"  Tempo de entrega","iconUrl":"https://duyt4h9nfnj50.cloudfront.net/sort_and_filters/filter-ic-etd.png"}],"selected":false},{"uuid":"2c7cf7ef-730f-431f-9072-46bc39f7c021","type":"priceRangeFilter","label":"Interval ... }

So far, so good. But we don’t really want to copy/translate all those HTTP headers into the Python code. So by trial and error we’ll remove each header and check if we keep getting the same response until we stick with the bare minimum headers. By doing such we end up with something like this.

curl 'https://www.ubereats.com/api/getFeedV1?localeCode=pt' \
  -H 'x-csrf-token: x' \
  -H 'content-type: application/json' \
  -H 'cookie: uev2.id.xp=a28c52ba-e720-40c1-93b3-3e9d0ee1988a; dId=d9021049-d7af-4ad9-920a-bc84db56d133; uev2.id.session=5127e00a-7141-4f41-9bec-c0f70d829cd6; uev2.ts.session=1633648711936; uev2.loc=%7B%22address%22%3A%7B%22address1%22%3A%22Marqu%C3%AAs%20de%20Pombal%22%2C%22address2%22%3A%22Lisboa%22%2C%22aptOrSuite%22%3A%22%22%2C%22eaterFormattedAddress%22%3A%221250-160%20Lisboa%2C%20Portugal%22%2C%22subtitle%22%3A%22Lisboa%22%2C%22title%22%3A%22Marqu%C3%AAs%20de%20Pombal%22%2C%22uuid%22%3A%22%22%7D%2C%22latitude%22%3A38.7249414%2C%22longitude%22%3A-9.1509968%2C%22reference%22%3A%22ChIJeXDfLXczGQ0Rj4rXXo3-iL0%22%2C%22referenceType%22%3A%22google_places%22%2C%22type%22%3A%22google_places%22%2C%22source%22%3A%22rev_geo_reference%22%2C%22addressComponents%22%3A%7B%22countryCode%22%3A%22PT%22%2C%22firstLevelSubdivisionCode%22%3A%22Lisboa%22%2C%22city%22%3A%22Lisboa%22%2C%22postalCode%22%3A%221250-160%22%7D%2C%22originType%22%3A%22user_autocomplete%22%7D; marketing_vistor_id=78e72918-ff6c-4a37-bb64-be298be77a24; jwt-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MzM2NDg3MTIsImV4cCI6MTYzMzczNTExMn0.-sZDzc__dhesxypJkvNZ0wtHzquiJ7DVuyTHmn7Avew' \
  --data-raw '{"cacheKey":"JTdCJTIyYWRkcmVzcyUyMiUzQSUyMk1hcnF1JUMzJUFBcyUyMGRlJTIwUG9tYmFsJTIyJTJDJTIycmVmZXJlbmNlJTIyJTNBJTIyQ2hJSmVYRGZMWGN6R1EwUmo0clhYbzMtaUwwJTIyJTJDJTIycmVmZXJlbmNlVHlwZSUyMiUzQSUyMmdvb2dsZV9wbGFjZXMlMjIlMkMlMjJsYXRpdHVkZSUyMiUzQTM4LjcyNDk0MTQlMkMlMjJsb25naXR1ZGUlMjIlM0EtOS4xNTA5OTY4JTdE/DELIVERY///0/0//JTVCJTVE/////","feedSessionCount":{"announcementCount":0,"announcementLabel":""},"showSearchNoAddress":false,"userQuery":"","date":"","startTime":0,"endTime":0,"carouselId":"","sortAndFilters":[],"marketingFeedType":"","billboardUuid":"","feedProvider":"","promotionUuid":"","targetingStoreTag":"","venueUuid":"","favorites":"","getFeedItemType":"DYNAMIC"}' \
  --compressed

And if we strip the cookies even more we’ll end up with this.

curl 'https://www.ubereats.com/api/getFeedV1?localeCode=pt' \
  -H 'x-csrf-token: x' \
  -H 'content-type: application/json' \
  -H 'cookie: uev2.loc=%7B%22address%22%3A%7B%22address1%22%3A%22Marqu%C3%AAs%20de%20Pombal%22%2C%22address2%22%3A%22Lisboa%22%2C%22aptOrSuite%22%3A%22%22%2C%22eaterFormattedAddress%22%3A%221250-160%20Lisboa%2C%20Portugal%22%2C%22subtitle%22%3A%22Lisboa%22%2C%22title%22%3A%22Marqu%C3%AAs%20de%20Pombal%22%2C%22uuid%22%3A%22%22%7D%2C%22latitude%22%3A38.7249414%2C%22longitude%22%3A-9.1509968%2C%22reference%22%3A%22ChIJeXDfLXczGQ0Rj4rXXo3-iL0%22%2C%22referenceType%22%3A%22google_places%22%2C%22type%22%3A%22google_places%22%2C%22source%22%3A%22rev_geo_reference%22%2C%22addressComponents%22%3A%7B%22countryCode%22%3A%22PT%22%2C%22firstLevelSubdivisionCode%22%3A%22Lisboa%22%2C%22city%22%3A%22Lisboa%22%2C%22postalCode%22%3A%221250-160%22%7D%2C%22originType%22%3A%22user_autocomplete%22%7D;' \
  --data-raw '{"cacheKey":"JTdCJTIyYWRkcmVzcyUyMiUzQSUyMk1hcnF1JUMzJUFBcyUyMGRlJTIwUG9tYmFsJTIyJTJDJTIycmVmZXJlbmNlJTIyJTNBJTIyQ2hJSmVYRGZMWGN6R1EwUmo0clhYbzMtaUwwJTIyJTJDJTIycmVmZXJlbmNlVHlwZSUyMiUzQSUyMmdvb2dsZV9wbGFjZXMlMjIlMkMlMjJsYXRpdHVkZSUyMiUzQTM4LjcyNDk0MTQlMkMlMjJsb25naXR1ZGUlMjIlM0EtOS4xNTA5OTY4JTdE/DELIVERY///0/0//JTVCJTVE/////","feedSessionCount":{"announcementCount":0,"announcementLabel":""},"showSearchNoAddress":false,"userQuery":"","date":"","startTime":0,"endTime":0,"carouselId":"","sortAndFilters":[],"marketingFeedType":"","billboardUuid":"","feedProvider":"","promotionUuid":"","targetingStoreTag":"","venueUuid":"","favorites":"","getFeedItemType":"DYNAMIC"}' \
  --compressed

Notice that we only kept the uev2.loc cookie, which apparently is being used as input for our exact delivery address. Using a URL decoding helper like this you can see the JSON content of it.

{
  "address": {
    "address1": "Marquês de Pombal",
    "address2": "Lisboa",
    "aptOrSuite": "",
    "eaterFormattedAddress": "1250-160 Lisboa, Portugal",
    "subtitle": "Lisboa",
    "title": "Marquês de Pombal",
    "uuid": ""
  },
  "latitude": 38.7249414,
  "longitude": -9.1509968,
  "reference": "ChIJeXDfLXczGQ0Rj4rXXo3-iL0",
  "referenceType": "google_places",
  "type": "google_places",
  "source": "rev_geo_reference",
  "addressComponents": {
    "countryCode": "PT",
    "firstLevelSubdivisionCode": "Lisboa",
    "city": "Lisboa",
    "postalCode": "1250-160"
  },
  "originType": "user_autocomplete"
}

Back to the code

So for the first part of calling the API, we think the excerpt below summarizes what we did with cURL.

import requests
import json

url = "https://www.ubereats.com/api/getFeedV1?localeCode=pt"

data = {
    "cacheKey": "JTdCJTIyYWRkcmVzcyUyMiUzQSUyMk1hcnF1JUMzJUFBcyUyMGRlJTIwUG9tYmFsJTIyJTJDJTIycmVmZXJlbmNlJTIyJTNBJTIyQ2hJSmVYRGZMWGN6R1EwUmo0clhYbzMtaUwwJTIyJTJDJTIycmVmZXJlbmNlVHlwZSUyMiUzQSUyMmdvb2dsZV9wbGFjZXMlMjIlMkMlMjJsYXRpdHVkZSUyMiUzQTM4LjcyNDk0JTJDJTIybG9uZ2l0dWRlJTIyJTNBLTkuMTUwOTkyMiU3RA",
    "feedSessionCount": {
        "announcementCount": 0,
        "announcementLabel": ""
    },
    "showSearchNoAddress": False,
    "userQuery": "",
    "date": "",
    "startTime": 0,
    "endTime": 0,
    "carouselId": "",
    "sortAndFilters": [{
        "uuid": "2c7cf7ef-730f-431f-9072-27bc39f7c021",
        "options": [{
            "uuid": "2c7cf7ef-730f-431f-9072-26bc39f7c025"
        }]
    }],
    "marketingFeedType": "",
    "billboardUuid": "",
    "feedProvider": "",
    "promotionUuid": "",
    "targetingStoreTag": "",
    "venueUuid": "",
    "favorites": "",
    "pageInfo": {
        "offset": 0,
        "pageSize": 10000
    }}

r = requests.post(url, data=json.dumps(data), headers={
    "content-type": "application/json",
    "x-csrf-token": "x",
    "cookie": "uev2.loc=%7B%22address%22%3A%7B%22address1%22%3A%22Marqu%C3%AAs%20de%20Pombal%22%2C%22address2%22%3A%22Lisboa%22%2C%22aptOrSuite%22%3A%22%22%2C%22eaterFormattedAddress%22%3A%221250-160%20Lisboa%2C%20Portugal%22%2C%22subtitle%22%3A%22Lisboa%22%2C%22title%22%3A%22Marqu%C3%AAs%20de%20Pombal%22%2C%22uuid%22%3A%22%22%7D%2C%22latitude%22%3A38.72494%2C%22longitude%22%3A-9.1509922%2C%22reference%22%3A%22ChIJeXDfLXczGQ0Rj4rXXo3-iL0%22%2C%22referenceType%22%3A%22google_places%22%2C%22type%22%3A%22google_places%22%2C%22source%22%3A%22manual_auto_complete%22%2C%22addressComponents%22%3A%7B%22countryCode%22%3A%22PT%22%2C%22firstLevelSubdivisionCode%22%3A%22Lisboa%22%2C%22city%22%3A%22Lisboa%22%2C%22postalCode%22%3A%221250-160%22%7D%2C%22originType%22%3A%22user_autocomplete%22%7D;",
})

if r.status_code != 200:
    print("Unexpected response code.")
    print(r.status_code)
    exit()

We then need to filter it according to our pseudo-code and notify in case there are specific deals on going for our favorite restaurants. This is basic array manipulation, and it can be done in multiple ways. The code below just exemplifies how you can achieve it.

feedItems = r.json()['data']['feedItems']
print(len(feedItems), "items")

def buyOneGetOneFreeFilterFunction(feedItem):
    if feedItem["type"] == "MINI_STORE" and feedItem["title"]["text"] == "Best restaurant":
        for signpost in feedItem["store"]["signposts"]:
            if signpost["text"] == "Compre 1, receba 1 grátis":
                return True
    return False

buyOneGetOne = filter(buyOneGetOneFreeFilterFunction, feedItems)

Finally, we want to be notified via e-mail.

def send_mail(subject, body): 
    message = MIMEMultipart()
    message['From'] = <your-smtp-user>
    message['To'] = <your-smtp-user>
    message['Subject'] = subject
    message.attach(MIMEText(body, 'plain'))

    session = smtplib.SMTP('smtp.gmail.com', 587)
    session.starttls()
    session.login(user, app_key)
    text = message.as_string()
    session.sendmail(user, user, text)
    session.quit()

On the second-part of this article we’ll make the entire code available and guide you through the basic usage of Crontask.io application, set up a Task and schedule it for period checks.