Digging Into Food Delivery With Chowline: Reverse Engineering UberEats and DoorDash

Tuesday, November 25, 2025

Digging Into Food Delivery With Chowline: Reverse Engineering UberEats and DoorDash

link What Website Is Next?

Shortly after I scraped a bunch of Nintendo games before they were taken offline I started searching for the next online service of interest. Around that time the burrito taxi meme rode onto the scene. With food delivery on my mind I thought about how major food delivery services make it difficult to do keyword searches on menu items directly, as well as sort by price. I considered what it would entail to tackle UberEats as my next adventure.

I started with UberEats because, after a cursory glance at both the coverage and ease of reverse engineering, it seemed the most vulnerable to a naive approach while also having significant coverage across the US. I was tackling some mobile apps with cert pinning at that time and needed an easier diversion. For example, the following POST returns all information relevant to a restaurant in one JSON blob:

1POST https://www.ubereats.com/_p/api/getStoreV1
2
3{"storeUuid":"f8a9e52b-0eea-5c25-b3ba-3af02fd7ed86","diningMode":"DELIVERY","time":{"asap":true},"cbType":"EATER_ENDORSED"}

DoorDash, the true largest food delivery app in the US, did not have a JSON or similar endpoint I could find during my initial research. With a preference for data interchange formats I opted for the service that did not require parsing a markup format.

With the online service chosen it was time to consider what format to store the data in. I knew I needed a relational database to make simple proximity and column matching queries fast, as well as sort them. I went with SQLite so I could distribute the resulting database easily.

Later I came across a breakthrough that allowed me to tackle DoorDash as well, covering a full 90% of the market.

link Another Presentation

For BlasterHacks, the main hackathon hosted by my Alma Mater the Colorado School of Mines, I endeavored to create a workshop for my most recent reverse engineering project. I hoped it would inspire my peers to consider making a project in that oft-misunderstood field. Should that fail I hoped to at least entertain with a funny demo.

While creating visuals for the presentation I made this April Fools tweet:

The workshop walked through my process of obtaining useful data out of UberEats and some surprises I found. In this blogpost I will do the same.

link How To Cover The Space

Consider a body of water I want to map by stitching pictures together. One approach that is easy to perform but inefficient in the long run is flying a drone around the surface and randomly taking pictures. Another is taking pictures in a systematic pattern, like a lawnmower, as this would guarantee coverage in a computable time. While the latter is more efficient for the purposes of the workshop I was creating I started with the random selection approach. This approach made some sense in this circumstance:

If UberEats allowed the user to page through all the restaurants within a certain radius of their address it would be possible to iterate over a hexagonal covering. This is not the case as UberEats caps the number of results on the homepage to 500. If results were guaranteed to be in order of proximity then a varying size circle covering, with the coverage made by a specific request being determined by the farthest restaurant returned in the results, would also be possible. This is also not guaranteed as seen below:

Finally restaurants control what distance they deliver to. If there were an abnormally large radius of results, due to the 500 cap and at least once restaurant advertising delivery at far distances, it would be possible to exclude potential restaurants under the previously described algorithm. All this to say any systematic approach, save a discovery of a globally incrementing ID, would be a tall order and overkill for the purposes of a hackathon demo.

link Getting Started

When initially navigating to https://www.ubereats.com the user is required to select an address, using which UberEats generates your homepage feed; the crux of my scraping strategy. But this dialog does not set a variable in memory and trigger a rerender; it actually triggers a POST to https://www.ubereats.com/_p/api/upsertDeliveryLocationV2 then a POST to https://www.ubereats.com/_p/api/setTargetLocationV1. This latter requests sets the cookie value uev2.loc, your delivery address, to a payload similar to the below:

 1{
 2    "address": {
 3        "address1": "Denver Union Station",
 4        "address2": "1701 Wynkoop St, Denver, CO",
 5        "aptOrSuite": "",
 6        "eaterFormattedAddress": "1701 Wynkoop St, Denver, CO 80202-1026, US",
 7        "subtitle": "1701 Wynkoop St, Denver, CO",
 8        "title": "Denver Union Station",
 9        "uuid": ""
10    },
11    "latitude": 39.75309,
12    "longitude": -104.99999,
13    "reference": "here:pds:place:8409xj64-c2d63515319ae95012aced8b7c09c9d8",
14    "referenceType": "here_places",
15    "type": "here_places",
16    "addressComponents": {
17        "city": "Denver",
18        "countryCode": "US",
19        "firstLevelSubdivisionCode": "CO",
20        "postalCode": "80202-1026"
21    },
22    "categories": [
23        "RESTAURANT",
24        "LANDMARK",
25        "SHOPS_AND_SERVICES",
26        "FOOD_AND_BEVERAGE",
27        "place"
28    ],
29    "originType": "user_autocomplete",
30    "source": "manual_auto_complete",
31    "userState": "Unknown"
32}

This means UberEats is tracking delivery address for their users server-side. This makes perfect sense as it allows a user to preserve their delivery address between devices. Keeping this in mind let’s look for the endpoint that populates homepage results for this delivery address. Filtering by response type JSON we find a POST to https://www.ubereats.com/_p/api/getFeedV1. Critically the payload does not contain either a delivery address or a coordinate. So this probably means this request uses the cookie set earlier. The payload is rather empty, besides the field cacheKey;

1{
2    "cacheKey": "JTdCJTI...",
3    "feedSessionCount": {
4        "announcementCount": 0,
5        "announcementLabel": ""
6    },
7    "userQuery": "",
8    ...
9}

So as a test we’ll craft our first feed request with a minimal set of data:

 1import requests
 2
 3homepage_feed = json.loads(
 4    requests.post(
 5        url="https://www.ubereats.com/_p/api/getFeedV1",
 6        data="{}",
 7        cookies={
 8            "uev2.loc": json.dumps({
 9                "latitude": 39.75309,
10                "longitude": -104.99999,
11            }),
12        }
13    ).text
14)
15
16print(homepage_feed)

First snag: The response is:

1HTTP/1.1 403 Forbidden
2Content-Type: text/plain
3
4Missing csrf token.

Lets look through any parameters, either explicitly provided or present in the cookies, that mention "csrf". Looking through the request headers we see:

1x-csrf-token: x
2x-uber-client-gitref: 760f...
3x-uber-request-id: 8951...
4x-uber-session-id: ff44...

Let’s change the code to include the x-csrf-token: x header.

 1import requests
 2import json
 3
 4homepage_feed = json.loads(
 5    requests.post(
 6        url="https://www.ubereats.com/_p/api/getFeedV1",
 7        data="{}",
 8        headers={
 9            "x-csrf-token": "x",
10        },
11        cookies={
12            "uev2.loc": json.dumps(
13                {
14                    "latitude": 39.75309,
15                    "longitude": -104.99999,
16                }
17            ),
18        },
19    ).text
20)
21
22print(homepage_feed)

link Feed Info

With that we already get a 1.4MB JSON blob; good sign! Iterating through data.feedItems we find our useful info:

  1{
  2  "uuid": "fbe9e6db-7b34-5b9b-8c68-81fdefccfcd1",
  3  "type": "REGULAR_STORE",
  4  "store": {
  5    "storeUuid": "fbe9e6db-7b34-5b9b-8c68-81fdefccfcd1",
  6    "title": {
  7      "text": "Runza"
  8    },
  9    "meta": [
 10      {
 11        "text": "Closed",
 12        "badgeType": "CLOSED"
 13      }
 14    ],
 15    "imageOverlay": {
 16      "backgroundColor": {
 17        "alpha": 0.5,
 18        "color": "#000000"
 19      },
 20      "iconColor": {
 21        "alpha": 1,
 22        "color": "#FFFFFF"
 23      },
 24      "iconUrl": "https://d4p17acsd5wyj.cloudfront.net/eatsfeed/other_icons/restaurant_closed.png",
 25      "text": "Closed",
 26      "textColor": {
 27        "alpha": 1,
 28        "color": "#FFFFFF"
 29      },
 30      "badgeType": "StoreNotOrderable"
 31    },
 32    "rating": {
 33      "text": "4.5",
 34      "accessibilityText": "Rated 4.5 out of 5 stars based on more than 110 reviews.",
 35      "badgeType": "RATINGS"
 36    },
 37    "actionUrl": "/store/runza-loveland-co/--nm23s0W5uMaIH978z80Q?diningMode=DELIVERY",
 38    "favorite": false,
 39    "image": {
 40      "items": [
 41        {
 42          "url": "https://tb-static.uber.com/prod/image-proc/processed_images/b42088c822eb98f293032369bc8b49aa/fb86662148be855d931b37d6c1e5fcbe.jpeg",
 43          "width": 2880,
 44          "height": 2304
 45        },
 46        ...
 47      ]
 48    },
 49    "signposts": null,
 50    "tracking": {
 51      "metaInfo": {
 52        "pluginName": "StorefrontFeedPlugin",
 53        "analyticsLabel": "store_front",
 54        "verticalType": "UNKNOWN",
 55        "category": "",
 56        "subcategory": "",
 57        "surfaceAreaV2": "HOME_FEED"
 58      },
 59      "storePayload": {
 60        "storeUUID": "fbe9e6db-7b34-5b9b-8c68-81fdefccfcd1",
 61        "isOrderable": false,
 62        "score": {
 63          "breakdown": {
 64            "t120d_eyeball_count": 884,
 65            "t120d_request_count": 24
 66          },
 67          "total": 0
 68        },
 69        "ratingInfo": {
 70          "storeRatingScore": 4.461038961038961,
 71          "ratingCount": "110+"
 72        },
 73        "scheduleTimeSlots": null,
 74        "storeAvailablityState": "STORE_CLOSED"
 75      }
 76    },
 77    "mapMarker": {
 78      "latitude": 40.4151,
 79      "longitude": -105.0725,
 80      "zIndex": -10000,
 81      "description": {
 82        "title": "Runza",
 83        "color": "CONTENT_INVERSE_TERTIARY",
 84        "backgroundColor": "BACKGROUND_PRIMARY",
 85        "selectedColor": "CONTENT_PRIMARY",
 86        "selectedBackgroundColor": "BACKGROUND_PRIMARY"
 87      },
 88      "markerContent": {
 89        "color": "CONTENT_INVERSE_PRIMARY",
 90        "selectedColor": "CONTENT_INVERSE_PRIMARY",
 91        "selectedBackgroundColor": "BACKGROUND_INVERSE_PRIMARY",
 92        "icon": "CIRCLE_SMALL",
 93        "size": "SPACING_UNIT_2X"
 94      }
 95    },
 96    "meta2": [
 97      {
 98        "text": "Available Wednesday 11:00 AM",
 99        "textFormat": "<span style=\"font-family:S2;color:#048848;\">Available Wednesday 11:00 AM</span>"
100      }
101    ],
102    "storyIconPayload": {
103      "isIconVisible": false
104    },
105    "endorsements": null,
106    "meta4": null
107  },
108  "analyticsLabel": "filters_selected_storefront"
109}

You can tell I was writing this at midnight as everything is closed! The UUID will probably enable us to query further info, but at the moment let us figure out how to get to this store in the browser.

The action URL looks curiously verbose, as it has the address embedded within it. Trying to modify /store/runza-loveland-co/--nm23s0W5uMaIH978z80Q?diningMode=DELIVERY to /store/--nm23s0W5uMaIH978z80Q does not, however, work. So we need to keep that verbose URL around. We can shorten it to /store/runza-loveland-co/--nm23s0W5uMaIH978z80Q as needed.

This JSON is essentially a light markup language that is then hydrated by the client, made clear by the type field. The rest of the front page is made up of carousels, like "National favorites" and "Under $2 Delivery Fee", that will hopefully convince you to drop cash on the burrito taxi, as well as other formatting. Some types are REGULAR_CAROUSEL and DIVIDER. We will be iterating out any items that are not a REGULAR_STORE.

The rest are useful. Its name, open status, rating, coordinates (on a map that is not rendered) and banner image. The rating is interesting as the service bothers to give us an exceptionally precise double but does not give us a precise number of ratings, only a rough magnitude.

score gives us some insight on how UberEats is ranking restaurants internally for their front page. Other than just the standard graph neural network (GNN) based recommendation system, used by companies like Amazon and Twitter, UberEats appears to be using a Tobii T120 eye tracker in some isolated "Human-in-the-Loop" approach. They record the number of times a volunteer looks at a particular restaurant listing on the front page and how many times they choose to click on it.

score can occasionally contain much more insightful information, as if UberEats is exposing the raw model features to the client:

 1"score": {
 2  "breakdown": {
 3    "ConversionRatePredictionScore": 0.04325512424111366,
 4    "ConversionRateScoreCoefficient": 1.78,
 5    "FinalScore": 0.0879280784074217,
 6    "NetInflowPredictionScore": 22.027420043945312,
 7    "NetInflowScoreCoefficient": 0,
 8    "PredictionScore": 0.0879280784074217,
 9    "ServiceQualityPredictionScore": 0.95,
10    "ServiceQualityScoreCoefficient": 0,
11    "beta_coefficient_scaling_factor": 0,
12    "conversion_rate_boosting_factor": 1.78,
13    "conversion_rate_partial_score": 0.04325512424111366,
14    "ctr_boosting_factor": 1.78,
15    "ctr_partial_score": 0.0061426726169884205,
16    "lw_conversion_rate_boosting_factor": 1.78,
17    "lw_conversion_rate_partial_score": 0.0061426726169884205,
18    "net_inflow_boosting_factor": 0,
19    "net_inflow_partial_score": 22.027420043945312,
20    "service_quality_boosting_factor": 0,
21    "service_quality_partial_score": 0.95,
22    "t120d_eyeball_count": 651,
23    "t120d_request_count": 159
24  },
25  "total": 0.0879280784074217
26},

This could be A/B testing in 2025 or some legacy code.

Finally, in order to query the entire front page for a particular location, up to the UberEats limit of 500, we need to start paging through the results. As this first request always starts with 50 lets see what happens when we select "Show more". When observing how the POST request changes we see the addition of a property pageInfo. Lets incorporate that:

 1import requests
 2import json
 3
 4offset = 0
 5stores = []
 6
 7while True:
 8    page = json.loads(
 9        requests.post(
10            url="https://www.ubereats.com/_p/api/getFeedV1",
11            data=json.dumps(
12                {
13                    "pageInfo": {"offset": offset, "pageSize": 80},
14                }
15            ),
16            headers={
17                "Accept-Language": "en-US,en;q=0.5",
18                "Content-Type": "application/json",
19                "x-csrf-token": "x",
20            },
21            cookies={
22                "uev2.loc": json.dumps(
23                    {
24                        "latitude": 39.75309,
25                        "longitude": -104.99999,
26                    }
27                ),
28            },
29        ).text
30    )
31
32    stores = stores + page["data"]["feedItems"]
33    offset += len(page["data"]["feedItems"])
34
35    if not page["data"]["meta"]["hasMore"]:
36        break
37
38print(
39    [
40        store["store"]["title"]["text"] if store["type"] == "REGULAR_STORE" else None
41        for store in stores
42    ]
43)

Interestingly without both the headers Accept-Language and Content-Type paging never returns results past the first page. The latency of the first call (being much slower) versus all later ones also suggests an entirely different codepath is used for offsets besides zero.

Considering the relative inconsistency of the feed endpoint (providing tracking info but not address) we have to keep digging into endpoints to get further info.

When clicking on stores from the feed, not by navigating to their URL outright, we get a clientside navigation to the store page that triggers a POST to https://www.ubereats.com/_p/api/getStoreV1. Lets investigate.

link Store Info

The payload that is returned looks like the following:

  1{
  2    "status": "success",
  3    "data": {
  4        "title": "Runza (Loveland, CO)",
  5        "uuid": "fbe9e6db-7b34-5b9b-8c68-81fdefccfcd1",
  6        "slug": "runza-loveland-co",
  7        "citySlug": "fort-collins",
  8        "heroImageUrls": [...],
  9        "brandInfo": [],
 10        "seoMeta": {...},
 11        "location": {
 12            "address": "2204 North Lincoln Avenue, Loveland, CO 80538",
 13            "streetAddress": "2204 North Lincoln Avenue",
 14            "city": "Loveland",
 15            "country": "US",
 16            "postalCode": "80538",
 17            "region": "CO",
 18            "latitude": 40.4150594,
 19            "longitude": -105.072501,
 20            "geo": {
 21                "city": "loveland-co",
 22                "country": "us",
 23                "region": "co"
 24            },
 25            "locationType": "PHYSICAL"
 26        },
 27        "currencyCode": "USD",
 28        "isDeliveryThirdParty": false,
 29        "isDeliveryOverTheTop": false,
 30        "isOrderable": true,
 31        "etaRange": {
 32            "text": "14–14 Min",
 33            "iconUrl": "",
 34            "accessibilityText": "Delivered in 14 to 14 min"
 35        },
 36        "fareBadge": null,
 37        "rating": {
 38            "ratingValue": 4.5,
 39            "reviewCount": "110+"
 40        },
 41        "eatsPassExclusionBadge": null,
 42        "indicatorIcons": [],
 43        "storeInfoMetadata": {
 44            "rawRatingStats": {
 45                "storeRatingScore": 4.46103896103896,
 46                "ratingCount": "110+"
 47            },
 48            ...
 49            "storeAvailablityStatus": {
 50                "state": "AVAILABLE",
 51                "displayMessage": "",
 52                "displayIconUrl": ""
 53            },
 54            "groupOrderingConfig": {
 55                "isAvailable": true,
 56                ...
 57                "recommendationsConfig": {
 58                    "recommendedGroupSize": 15
 59                }
 60            },
 61            "assistanceProgramSections": null
 62        },
 63        "hours": [
 64            {
 65                "dayRange": "Every Day",
 66                "sectionHours": [
 67                    {
 68                        "startTime": 660,
 69                        "endTime": 1245,
 70                        "sectionTitle": ""
 71                    }
 72                ]
 73            }
 74        ],
 75        "categories": [
 76            "Sandwiches",
 77            "Burgers"
 78        ],
 79        "categoryLinks": [
 80            {
 81                "text": "Sandwiches"
 82            },
 83            {
 84                "text": "Burgers"
 85            }
 86        ],
 87        "priceBucket": "",
 88        "modalityInfo": {...},
 89        "tags": [],
 90        "meta": {
 91            "sectionHoursInfo": [
 92                {
 93                    "dayRange": "Every Day",
 94                    "sectionHours": [
 95                        {
 96                            "startTime": 660,
 97                            "endTime": 1245
 98                        }
 99                    ]
100                }
101            ],
102            "nextClosingTimestamp": {
103                "high": 410,
104                "low": 337308640,
105                "unsigned": false
106            }
107        },
108        "metaJson": "...",
109        "shouldIndex": true,
110        "isOpen": true,
111        "isPreview": false,
112        "closedMessage": "",
113        "sectionEntitiesMap": {},
114        "sections": [
115            {
116                "subsectionUuids": [
117                    "0379a62a-1156-4ddb-a09a-7e1dc45d5dfb",
118                    "197ed9ab-6923-4bc8-81c6-08ad39ffe90b",
119                    "19258695-c534-5e29-b78f-cf3c693f5793",
120                    "2b8a93e0-de0f-42ae-b406-9bfac57bc2f4",
121                    "453aff47-0625-4ea6-b065-380cb322b116",
122                    "eb46f9e2-7e03-519c-bd61-bb4a4f6d0bf6",
123                    "8da12f2d-c83f-4c87-94cb-8f8795c0f9ff",
124                    "c98a3cb0-5eee-4228-a432-f1e1c4f1f594",
125                    "be5532e4-bf1a-4fed-8aa8-11475b82971a",
126                    "307ad5b6-96d9-5b54-b871-0c6e73f89d38",
127                    "9a8a8b71-6eed-44d9-a8fc-1aaf07f4f75f",
128                    "dcbce44f-6edc-48ce-9fd2-b7f290c7f3d2",
129                    "dddef470-e713-5164-99b4-9c2de9756350"
130                ],
131                "title": "Menu",
132                "subtitle": "11:00 AM – 8:45 PM",
133                "uuid": "cb160232-1502-46f8-8c5a-f5841330ac49",
134                "isTop": true,
135                "isOnSale": true
136            }
137        ],
138        "subsectionsMap": {},
139        "sanitizedTitle": "Runza (Loveland, CO)",
140        "cityId": 1414,
141        "cuisineList": [
142            "Sandwiches",
143            "Burgers"
144        ],
145        "disclaimerBadge": null,
146        "distanceBadge": {
147            "text": "7.2 mi",
148            "accessibilityText": "7.2 miles"
149        },
150        "fareInfo": {
151            "serviceFeeCents": null
152        },
153        "promotion": null,
154        "hasStorePromotion": false,
155        "isDeliveryBandwagon": false,
156        "disableOrderInstruction": true,
157        "disableCheckoutInstruction": true,
158        "nuggets": [],
159        "isWithinDeliveryRange": true,
160        "phoneNumber": "+19706692131",
161        "promoTrackings": [],
162        "suggestedPromotion": {
163            "text": "",
164            "promotionUuid": ""
165        },
166        "supportedDiningModes": [
167            {
168                "mode": "DELIVERY",
169                "title": "Delivery",
170                "isAvailable": true,
171                "isSelected": true
172            },
173            {
174                "mode": "PICKUP",
175                "title": "Pickup",
176                "isAvailable": true,
177                "isSelected": false
178            }
179        ],
180        "isFavorite": false,
181        "isLost": false,
182        "onboardingStatusUpdatedAt": "2021-12-28T16:48:08.000Z",
183        "menuDisplayType": "STANDARD",
184        "specialInstructionHintText": "Add a note",
185        "fulfillmentIssueOptions": {},
186        "shouldRenderAllItems": true,
187        "shouldVirtualizeOFDCatalogItems": false,
188        "adaptedDeliveryHoursInfos": {
189            "dates": [
190                "2025-10-24",
191                "2025-10-25",
192                "2025-10-26",
193                "2025-10-27",
194                "2025-10-28",
195                "2025-10-29"
196            ],
197            "timeRanges": {
198                "2025-10-24": [
199                    {
200                        "startTime": 720,
201                        "endTime": 750
202                    },
203                    ...
204                    {
205                        "startTime": 1230,
206                        "endTime": 1260
207                    }
208                ],
209                "2025-10-25": [...],
210                ...
211            },
212            "scheduledTimesAvailable": true
213        },
214        "eaterConsent": null,
215        "orderForLaterInfo": {
216            "nextOpenTime": null,
217            "isSchedulable": null,
218            "bottomSheetTitleMessage": "Pick a time",
219            "bottomSheetSubtitleMessage": null,
220            "bottomSheetPrimaryButtonMessage": "Schedule",
221            "bottomSheetPrimaryButtonAction": "SCHEDULE",
222            "bottomSheetSecondaryButtonMessage": null,
223            "bottomSheetSecondaryButtonAction": null,
224            "determinedFromBackend": false
225        },
226        "siteCustomizations": null,
227        "scheduledOrderInfo": {
228            "isSchedulable": true
229        },
230        "storeReviews": [
231            {
232                "reviewText": {
233                    "text": "super yummy",
234                    "textFormat": "<span style=\"font-family:M1;color:#545454\">super yummy</span>"
235                },
236                "source": "UNKNOWN",
237                "createdAt": "2024-02-24T00:00:00",
238                "eaterName": {
239                    "text": "DEIDRE M.",
240                    "textFormat": "<span style=\"font-family:LM;color:#000000\">DEIDRE M.</span>"
241                },
242                "contentUUID": "b2d6a5cd-fe34-4234-9c27-8e9b09ec25a4",
243                "timeSinceReview": "2 years ago",
244                "avatarUrl": "/_static/9f716d4b83f1173e.svg",
245                "formattedDate": "02/24/24"
246            },
247            ...
248        ],
249        "featuredReviews": [
250            {
251                "reviewText": {
252                    "text": "The chili didn’t look anything like the picture, but it was delicious. Everything tasted great. I’ll order again 100%",
253                    "textFormat": "<span style=\"font-family:M1;color:#545454\">The chili didn’t look anything like the picture, but it was delicious. Everything tasted great. I’ll order again 100%</span>"
254                },
255                "source": "UNKNOWN",
256                "createdAt": "2022-04-01T00:00:00",
257                "eaterName": {
258                    "text": "Jake K.",
259                    "textFormat": "<span style=\"font-family:LM;color:#000000\">Jake K.</span>"
260                },
261                "contentUUID": "97b3f0b6-83c1-4fcb-83b5-80def1420869",
262                "timeSinceReview": "4 years ago",
263                "avatarUrl": "/_static/d590fac5df89924d.svg",
264                "formattedDate": "04/01/22"
265            },
266            ...
267        ],
268        "topReviewsBucket": "market_level_control",
269        "workingHoursTagline": "Open until 8:45 PM",
270        "hygieneRatingBadge": {
271            "iconUrl": ""
272        },
273        "parentChain": {
274            "uuid": "f5057546-e185-4432-9674-e517b0a15b3a",
275            "name": "runza national parent"
276        },
277        "breadcrumbs": {
278            "value": [
279                {
280                    "href": "/",
281                    "title": "United States",
282                    "hrefVariants": null
283                },
284                {
285                    "href": "/region/co",
286                    "title": "Colorado",
287                    "hrefVariants": null
288                },
289                {
290                    "href": "/city/loveland-co",
291                    "title": "Loveland",
292                    "hrefVariants": {
293                        "DELIVERY": "/city/loveland-co",
294                        "PICKUP": "/pickup/loveland-co"
295                    }
296                },
297                {
298                    "href": "/store/runza-loveland-co/--nm23s0W5uMaIH978z80Q",
299                    "title": "Runza (Loveland, CO)",
300                    "hrefVariants": null
301                }
302            ],
303            "metaJson": "..."
304        },
305        "catalogSectionsMap": {
306            "b5de5d0d-f5af-520d-bfe3-c974adb1398e": [],
307            "cb160232-1502-46f8-8c5a-f5841330ac49": [
308                {
309                    "type": "VERTICAL_GRID",
310                    "catalogSectionUUID": "197ed9ab-6923-4bc8-81c6-08ad39ffe90b",
311                    "payload": {
312                        "standardItemsPayload": {
313                            "title": {
314                                "text": "Combo Meals"
315                            },
316                            "spanCount": 2,
317                            "catalogItems": [
318                                {
319                                    "uuid": "051ab680-2edd-4057-88ed-10c250ea3d74",
320                                    "imageUrl": "https://tb-static.uber.com/prod/image-proc/processed_images/f53b928f4e60db5c3dc747dcf7c9eb94/c67fc65e9b4e16a553eb7574fba090f1.jpeg",
321                                    "title": "Cheese Runza® Sandwich Combo Meal",
322                                    "itemDescription": "",
323                                    "price": 1299,
324                                    "priceTagline": {
325                                        "text": "$12.99",
326                                        "textFormat": "<span>$12.99</span>",
327                                        "accessibilityText": "$12.99"
328                                    },
329                                    "spanCount": 2,
330                                    "displayType": "LIST",
331                                    "titleBadge": {
332                                        "text": "Cheese Runza® Sandwich Combo Meal",
333                                        "textFormat": "<span>Cheese Runza® Sandwich Combo Meal</span>",
334                                        "accessibilityText": "Cheese Runza® Sandwich Combo Meal"
335                                    },
336                                    "isSoldOut": false,
337                                    "hasCustomizations": true,
338                                    "numAlcoholicItems": 0,
339                                    "subsectionUuid": "197ed9ab-6923-4bc8-81c6-08ad39ffe90b",
340                                    "isAvailable": true,
341                                    "purchaseInfo": {
342                                        "purchaseOptions": [],
343                                        "pricingInfo": {}
344                                    },
345                                    "sectionUuid": "cb160232-1502-46f8-8c5a-f5841330ac49",
346                                    "quickAddConfig": {
347                                        "shouldShow": true,
348                                        "isInteractionEnabled": false,
349                                        "position": "BOTTOM_TRAILING"
350                                    },
351                                    "labelPrimary": {...},
352                                    "headingPrimary": {...},
353                                    "itemAvailabilityState": "UNKNOWN",
354                                    "catalogItemAnalyticsData": {
355                                        "catalogSectionItemPosition": 0,
356                                        "endorsementMetadata": {
357                                            "endorsementType": "ratings",
358                                            "rating": "85%",
359                                            "numRatings": 7
360                                        }
361                                    },
362                                    "imageOverlayElements": null,
363                                    "itemThumbnailElements": null,
364                                    "isWebPdpSupported": false
365                                },
366                                {
367                                    "uuid": "d7bfd7b1-3c12-503d-8618-eb60d230e30a",
368                                    "imageUrl": "https://tb-static.uber.com/prod/image-proc/processed_images/d97fd6dddb6a43df741d9380e9f87e0e/c67fc65e9b4e16a553eb7574fba090f1.jpeg",
369                                    "title": "Double Cheeseburger Combo Meal",
370                                    "itemDescription": "",
371                                    "price": 1469,
372                                    ...
373                                },
374                                ...
375                            ],
376                            "sectionUUID": "cb160232-1502-46f8-8c5a-f5841330ac49",
377                            "catalogSectionAnalyticsData": {
378                                "catalogSectionPosition": 1
379                            },
380                            "paginationEnabled": false,
381                            "scores": null
382                        },
383                        "type": "standardItemsPayload"
384                    }
385                },
386                {
387                    "type": "VERTICAL_GRID",
388                    "catalogSectionUUID": "19258695-c534-5e29-b78f-cf3c693f5793",
389                    "payload": {
390                        "standardItemsPayload": {
391                            "title": {
392                                "text": "Featured Favorites"
393                            },
394                            "spanCount": 2,
395                            "catalogItems": [...],
396                            "sectionUUID": "cb160232-1502-46f8-8c5a-f5841330ac49",
397                            "catalogSectionAnalyticsData": {
398                                "catalogSectionPosition": 2
399                            },
400                            "paginationEnabled": false,
401                            "scores": null
402                        },
403                        "type": "standardItemsPayload"
404                    }
405                },
406                {
407                    "type": "VERTICAL_GRID",
408                    "catalogSectionUUID": "2b8a93e0-de0f-42ae-b406-9bfac57bc2f4",
409                    "payload": {
410                        "standardItemsPayload": {
411                            "title": {
412                                "text": "Runza® Sandwiches"
413                            },
414                            "spanCount": 2,
415                            "catalogItems": [...],
416                            "sectionUUID": "cb160232-1502-46f8-8c5a-f5841330ac49",
417                            "catalogSectionAnalyticsData": {
418                                "catalogSectionPosition": 3
419                            },
420                            "paginationEnabled": false,
421                            "scores": null
422                        },
423                        "type": "standardItemsPayload"
424                    }
425                },
426                ...
427            ]
428        },
429        "groupOrderSizes": [],
430        "hideFavoriteButton": false,
431        "isVenueStore": false,
432        "isGroupOrderingDisabled": false,
433        "timeWindowPicker": {...},
434        "headerBrandingInfo": {...},
435        "storeBanners": null,
436        "exploreMoreStores": {
437            "b5de5d0d-f5af-520d-bfe3-c974adb1398e": {
438                "top": {
439                    "adsExperimentalStorePayload": {
440                        "parent": {...},
441                        "children": [
442                            {
443                                "uuid": "6f751057-3eb2-4a6d-8d8b-0eec98dc1cfa",
444                                "title": {...},
445                                "rating": {...},
446                                "meta1": [
447                                    {
448                                        "label": {
449                                            "richText": {
450                                                "richTextElements": [...],
451                                                "accessibilityText": "Delivered in 10 to 10 min"
452                                            }
453                                        },
454                                        "type": "label"
455                                    }
456                                ],
457                                "meta2": null,
458                                "meta3": null,
459                                "meta4": null,
460                                "meta5": null,
461                                "images": [...],
462                                "favorite": false,
463                                "tracking": {
464                                    "metaInfo": {
465                                        "pluginName": "",
466                                        "analyticsLabel": "storefront_rule_based_recommendation_carousel",
467                                        "verticalType": "",
468                                        "category": "",
469                                        "subcategory": "",
470                                        "surfaceAreaV2": ""
471                                    },
472                                    "storePayload": {
473                                        "storeUUID": "6f751057-3eb2-4a6d-8d8b-0eec98dc1cfa",
474                                        "isOrderable": true,
475                                        "score": {
476                                            "breakdown": {
477                                                "LocalgraphScore": 0.942015528678894
478                                            },
479                                            "total": 0
480                                        },
481                                        "etdInfo": {
482                                            "dropoffETARange": {
483                                                "min": 10,
484                                                "max": 10,
485                                                "raw": 10
486                                            }
487                                        },
488                                        "ratingInfo": {
489                                            "storeRatingScore": 4.304556354916067,
490                                            "ratingCount": "500+"
491                                        },
492                                        "scheduleTimeSlots": null,
493                                        "storeAvailablityState": "UNKNOWN"
494                                    }
495                                },
496                                "actionUrl": "/store/wendys-3710-s-college-ave/b3UQVz6ySm2Niw7smNwc-g",
497                                "template": "REGULAR_STORE",
498                                "badges": null,
499                                "tags": null,
500                                "images1": null,
501                                "signposts": null
502                            },
503                            ...
504                        ]
505                    }
506                },
507                "bottom": null
508            },
509            ...
510        },
511        "storeFrontActionPills": [...],
512        "seeSimilarSectionUuid": "b5de5d0d-f5af-520d-bfe3-c974adb1398e",
513        "timing": 490,
514        "eaterPreviewPrompts": null,
515        "storeMerchantTypeInfo": {
516            "uberMerchantType": {
517                "type": "MERCHANT_TYPE_RESTAURANT",
518                "restaurant": {
519                    "type": "RESTAURANT_MERCHANT_TYPE_UNKNOWN"
520                }
521            },
522            "isMultiLevel": false
523        },
524        "featuredItemsSections": {...},
525        "catalogSectionPagingInfo": {
526            "isFirstPage": true
527        },
528        "storeSessionUuid": "bc386a79-cade-4623-8079-464554ebd538",
529        "isGr": false
530    }
531}

So it’s a lot. But we do have an address, schedule, set of categories, phone number, set of reviews, and an UUID for the restaurant chain as a whole. But most importantly we now have an understanding of how menu items are structured.

UberEats stores menu items under sections and subsections. This allows us to conveniently blacklist sections with duplicate items. I managed to find "Featured items", "Buy 1, Get 1 Free" and "Picked for you" during my research.

Now to dissect menu items. As a reminder menu items look like the following:

 1{
 2    "uuid": "051ab680-2edd-4057-88ed-10c250ea3d74",
 3    "imageUrl": "https://tb-static.uber.com/prod/image-proc/processed_images/f53b928f4e60db5c3dc747dcf7c9eb94/c67fc65e9b4e16a553eb7574fba090f1.jpeg",
 4    "title": "Cheese Runza® Sandwich Combo Meal",
 5    "itemDescription": "",
 6    "price": 1299,
 7    "priceTagline": {
 8        "text": "$12.99",
 9        "textFormat": "<span>$12.99</span>",
10        "accessibilityText": "$12.99"
11    },
12    "spanCount": 2,
13    "displayType": "LIST",
14    "titleBadge": {
15        "text": "Cheese Runza® Sandwich Combo Meal",
16        "textFormat": "<span>Cheese Runza® Sandwich Combo Meal</span>",
17        "accessibilityText": "Cheese Runza® Sandwich Combo Meal"
18    },
19    "isSoldOut": false,
20    "hasCustomizations": true,
21    "numAlcoholicItems": 0,
22    "subsectionUuid": "197ed9ab-6923-4bc8-81c6-08ad39ffe90b",
23    "isAvailable": true,
24    "purchaseInfo": {
25        "purchaseOptions": [],
26        "pricingInfo": {}
27    },
28    "sectionUuid": "cb160232-1502-46f8-8c5a-f5841330ac49",
29    "quickAddConfig": {
30        "shouldShow": true,
31        "isInteractionEnabled": false,
32        "position": "BOTTOM_TRAILING"
33    },
34    "labelPrimary": {...
35    },
36    "headingPrimary": {...
37    },
38    "itemAvailabilityState": "UNKNOWN",
39    "catalogItemAnalyticsData": {
40        "catalogSectionItemPosition": 0,
41        "endorsementMetadata": {
42            "endorsementType": "ratings",
43            "rating": "85%",
44            "numRatings": 7
45        }
46    },
47    "imageOverlayElements": null,
48    "itemThumbnailElements": null,
49    "isWebPdpSupported": false
50}

We have a number of fields, like image URLs, price, availability and ratings, that will prove useful for making a UI for this data. Customizations is a UberEats feature that enables complexity on top of an existing menu item. It is a rabbit hole I chose not to explore too deeply this time; every item requires an individual call and there is an exceedingly large number of menu items in this dataset.

We now have an appropriate flow for this project:

  1. Make paged calls until hasMore is false
  2. Query store info for each store
  3. Create entries in a relational database with a small set of columns and the remaining data in a JSON blob

link The Other One

Let’s address the elephant in the room: Only 23% of the market? So I went about fixing that and covering DoorDash for a combined 90% market share. The primary problem became apparent immediately: DoorDash does not populate their homepage from a JSON request; they SSR (serverside render) HTML with prepopulated stores from a GET to https://www.doordash.com/home?newUser=true.

Assuming this were true I traveled down that path. I needed to figure out how to set my address and preserve my session, hopefully a cookie, with that information. After entering an address a POST is made to https://www.doordash.com/graphql/validateConsumerAddressWithAddressId?operation=validateConsumerAddressWithAddressId. When I noticed https://www.doordash.com/graphql/ I immediately stopped and realized DoorDash may have other known GraphQL endpoints. So I did something I regularly do with website reverse engineering: github searching.

After searching https://www.doordash.com/graphql/ I found a few promising projects. While I was able to find POST requests for store info I did not immediately find endpoints for querying stores from the front page. After returning to DoorDash I noticed the map icon. Upon clicking a POST request is made to https://www.doordash.com/graphql/pickupMapPage?operation=pickupMapPage. Bingo! In time (through a now deleted repo) I decided upon storepageFeed and pickupMapPage.

With that it’s time to begin making requests. But, unlike UberEats, DoorDash uses CloudFlare. So let’s try something new.

link CloudFlare

DoorDash uses at least CloudFlare’s "bot fight" mode, as __cf_bm is one of the cookies used by every request. While I did plenty of research into correctly implementing the headers __cf_bm, _cfuvid and cf_clearance (and DD’s own cookie ddweb_session_id) I eventually found that DoorDash only checks 3 things:

  1. SSL fingerprinting
  2. HTTP/2
  3. A few key headers

The wonderful project uTLS allows for mimicking SSL fingerprints of major browsers. I chose the most recent Firefox. For HTTP/2 I used the http2 library provided in Golang. Finally isolating the required headers was easy enough. Apollographql-Client-Name was curiously required, but I learned that ApolloGraph recommends enforcing this. In Go the following code was sufficient:

 1func (s *ScrapeDD) sendPost(url string, body []byte) (*http.Response, error) {
 2	// 1. TCP connection
 3	rawConn, err := net.Dial("tcp", "www.doordash.com:443")
 4	if err != nil {
 5		return nil, err
 6	}
 7
 8	// 2. uTLS handshake (with correct TLS fingerprinting)
 9	tlsConf := &utls.Config{ServerName: "www.doordash.com"}
10	uConn := utls.UClient(rawConn, tlsConf, utls.HelloFirefox_120)
11
12	if err := uConn.Handshake(); err != nil {
13		return nil, fmt.Errorf("TLS handshake failed: %w", err)
14	}
15
16	// 3. Upgrade to HTTP/2
17	h2Transport := &http2.Transport{}
18	h2Conn, err := h2Transport.NewClientConn(uConn)
19	if err != nil {
20		return nil, fmt.Errorf("HTTP2 connection failed: %w", err)
21	}
22
23	// 4. Build a standard http.Request
24	req, err := http.NewRequest("POST", url, bytes.NewReader(body))
25	if err != nil {
26		return nil, err
27	}
28
29	req.Header.Set("User-Agent", "Mozilla/5.0") // DD is ok with this apparently
30	req.Header.Set("Accept", "*/*")
31	req.Header.Set("Accept-Language", "en-US")
32	req.Header.Set("Referer", "https://www.doordash.com/pickup")
33	req.Header.Set("Content-Type", "application/json")
34	req.Header.Set("X-Experience-Id", "doordash")
35	req.Header.Set("X-Channel-Id", "marketplace")
36	req.Header.Set("Apollographql-Client-Name", "@doordash/app-consumer-production-ssr-client")
37	req.Header.Set("Apollographql-Client-Version", "3.0")
38	req.Header.Set("X-Csrftoken", "")
39	req.Header.Set("Origin", "https://www.doordash.com")
40	req.Header.Set("Alt-Used", "www.doordash.com")
41	req.Header.Set("Connection", "keep-alive")
42	req.Header.Set("Sec-Fetch-Dest", "empty")
43	req.Header.Set("Sec-Fetch-Mode", "cors")
44	req.Header.Set("Sec-Fetch-Site", "same-origin")
45	req.Header.Set("Priority", "u=4")
46	req.Header.Set("TE", "trailers")
47
48	// 5. Send request over a HTTP/2 connection
49	resp, err := h2Conn.RoundTrip(req)
50	if err != nil {
51		return nil, fmt.Errorf("round trip failed: %w", err)
52	}
53
54	return resp, nil
55}

With this function I could now implement my GraphQL requests. While doing so I thought I’d mess with the requested schema to see if some additional fields could be requested. For example, mimicking UberEats with a phone number field on phoneNumber. Suffice it to say unless you can debug query the server’s GraphQL schema, which is disabled in production, this is a shot in the dark. So we have what we have.

After a while DoorDash started returning rate limited regardless of proxy I was running. DoorDash was accepting Mozilla/5.0 as a user agent for a while but my traffic must have ticked them off. After this I had no problems using a standard user agent.

I’ll spare us from another JSON response dump and provide the columns that both services had in common and subsequently became our SQL table. For store info (both collected when querying the homepage and store info directly):

 1CREATE TABLE store (
 2	id TEXT PRIMARY KEY,
 3	source TEXT,
 4	name TEXT,
 5	rating REAL,
 6	url TEXT,
 7	image_url TEXT,
 8	lat REAL,
 9	lng REAL,
10	address TEXT,
11	delivery_available BOOLEAN,
12	pickup_available BOOLEAN,
13	parent_id TEXT, -- chain restaurant ID
14	parent_name TEXT,
15	extra_entry TEXT, -- JSON from homepage request
16	extra_info TEXT -- JSON from store request
17)

For menu items:

 1CREATE TABLE menu_item (
 2	store_id TEXT,
 3	section_id TEXT,
 4	subsection_id TEXT,
 5	id TEXT,
 6	name TEXT,
 7	description TEXT,
 8	price_cents INTEGER,
 9	image_url TEXT,
10	extra TEXT
11)

After adding some remaining tables, sections and subsections also have their own tables containing their names (like "Tacos" or "Drinks"), we now have the full system required to accurately mirror both UberEats and DoorDash’s catalog in the same database.

link Determining Price Correctly

The raw subtotal is not sufficient for practically searching for the cheapest DoorDash menu items in your city. As explained eloquently by the burrito taxi meme you are paying for delivery, among a number of other fees and taxes paid both to your driver and the company and state.

For UberEats a POST to https://www.ubereats.com/_p/api/createDraftOrderV2 is used if no items exist in the cart yet. Otherwise a POST to https://www.ubereats.com/_p/api/addItemsToDraftOrderV2 is made. The first call returns a draftOrderUUID. Using this UUID the final price to the user can be determined with a POST to https://www.ubereats.com/_p/api/getCheckoutPresentationV1 by summing the charges listed at data.checkoutPayloads.fareBreakdown.charges.

Considering the true price is dominated by the delivery fee, which is dependent on the location being delivered to, this process cannot be done for every menu item in the sizeable database. It has to be done for every item in every search made by a user of my site. So it exceeds the hassle and latency of customizations by an order of magnitude. So I decided against it as well for this demo.

With that decision the particular purpose of this work changes slightly. This doesn’t just have to be a frontend for purchasing food delivery directly; it can be a fun research tool!

link Relevance Outside Food Delivery

One funny thing about scraping food delivery is that they have done the work of encouraging restaurants across the nation to upload their menus as structured data online in a consistent format. This is especially relevant considering neither UberEats nor DoorDash requires restaurants to support delivery. Why not use that to our advantage to find cool dishes around our own cities?

For example I have been searching out wintermelon milk tea in Denver but UberEats, DoorDash and even Google Maps are extremely poor at this kind of search. They probably use a vector similarity search against an entire restaurant’s menu, instead of a keyword search. Generally a better idea, but in this case wintermelon can be an especially difficult flavor of milk tea to find, even at dedicated boba shops. Performing keyword searches on a 40 million menu item dataset does make it easier to find what I’m looking for.

Another item I have been searching out is chicken tikka masala. Much easier to find, but it can have a large variance in price. I hoped to use this dataset to find the cheapest chicken tikka masala in Denver. While I was not able to find adult-sized portions below 16$, I found something I did not expect. A chicken tikka masala cheesesteak? I went out to try it, as it is only 12$, and was pleasantly surprised to find it was a great deal in terms of the amount of protein you got. I never would have found it without a Denver keyword search.

Thinking about this dataset in this way could be beneficial for other kinds of research. But my favorite use of it is finding new restaurants in my area to try out.

link Statistics

This national food delivery dataset contains >880 thousand restaurants and >48 million menu items. Around 3/4 of stores scraped support delivery, so this dataset continues to primary benefit research related to food delivery. The average rating of a restaurant is 3.584 and median is 4.447. Unlike Japan, where the mean restaurant rating is 3.175 and median is 3.090 the United States is much more generous with ratings.

link Price Histogram

link Most Items in a Store

link Least Expensive

Prior to the patch sometime around July 2025 it was possible to get free food on UberEats. The system allowed menu items to have negative prices and did not error when they were added to your cart. There was an error however when the net price of a cart for a given store was negative. The easy fix was adding positive costs to offset the negative costs, which would make the cart approximately 0. This would have allowed ~6000$ (-60$ * 99) of free food from a store using the most negative cost in the country at the time I scraped this data. Now, when attempting this you trigger a business guardrail rule BREACHED for rules: RuleNegativeFare, meaning the cart is invalid if there are any negative cost menu items.

link Most Expensive

The most expensive menu items in the nation, surprisingly, have a similar guardrail. business guardrail rule BREACHED for rules: RuleUpperBound is such a high upper bound that it only triggers when the Lamb Biryani is added to the cart. The threshold is likely 10000$ for a single menu item. As for the cart it appears the net cart cost before taxes and fees is somewhere around ~13500$.

It used to be in the hundreds of thousands but that has been patched too. An entirely new feature since July 2025 is a check for a net cart size at checkout that is store specific. Some stores only support carts that are 150$ or less. Surprisingly low, you could not order nice sushi for your family with a cap that low!

For our own viewing pleasure this is what it looks like to order the maximum number (on this particular page) of the Lamb Biryani.

Also it might be clear from the data but Subway far and away makes this mistake the most. Increasing their prices by 3 orders of magnitude and the like.

link Franchise Sizes

link Ghost Kitchens

Or, most restaurants at the same address.

link Regions of the US

link Average Price

link Number of Restaurants

link Weird Food

link Not Food

link Search Yourself

With the data I’ve scraped in this project, which totals to ~40gb, I’ve made a small frontend for querying menu items by keyword. Items can be sorted by lowest price first, highest price first (yes there are some funny ones here) and geographically closest. Find the search page here.