How to Scrape OpenTable Data (Availability and Reviews)
Scrape OpenTable data that survives the next deploy: read ratings and reviews from each restaurant's JSON-LD, then pull availability from the GraphQL API.

To scrape OpenTable data that survives OpenTable's next deploy, split the job into two problems that behave nothing alike: the static facts and the live availability. Ratings, review counts, cuisine, price band, and address all sit in a schema.org JSON-LD block on every restaurant page, so those parse cleanly and almost never move. Reservation time slots are the hard part. OpenTable loads them from a GraphQL endpoint (/dapi/fe/gql) guarded by a CSRF token and a persisted-query hash it rotates between releases, so they are not in the initial HTML at all. This guide walks the full pipeline for public OpenTable data: searching a metro, reading ratings from JSON-LD, pulling live availability the durable way, and paging through verified-diner reviews without tripping the bot wall. Every request runs through SparkProxy's Scraping API, so browser rendering and residential IP rotation are request parameters instead of infrastructure you babysit.
Is scraping OpenTable data legal?
OpenTable restaurant profiles, ratings, and reviews are public: you can open a page and read all of it without logging in. That answers the access question, not the permission question, and the two are separate. Get the framing straight before you write code.
In the United States, the Ninth Circuit's decision in hiQ Labs v. LinkedIn (2022) held that scraping data that is publicly accessible, meaning no login and no authentication wall, generally does not violate the Computer Fraud and Abuse Act. That ruling is about unauthorized access, not a blanket license to copy. OpenTable's Terms of Use separately prohibit automated collection, and its robots.txt restricts crawlers on many paths. So scraping OpenTable can breach its terms and stated crawl policy even where it clears the CFAA bar. Different questions, different answers.
There is no public data API to fall back on. OpenTable retired its old affiliate and partner data API years ago, and what remains is a booking integration restricted to OpenTable's restaurant partners, not an open feed an outside analyst can request. That gap is exactly why people scrape the public pages for research, market analysis, and competitive tracking, and it is the gray-area space this guide operates in.
Guardrails that keep an OpenTable project defensible:
- Collect public data only: restaurant name, cuisine, rating, review count, price band, address, and published review text. Nothing behind a login or tied to a diner's account.
- Treat reviews as personal data. An OpenTable review ties a real diner's name to an opinion and a dining date, which is personal data under GDPR and CCPA. If you do not need reviewer identities, do not store them.
- Never turn availability scraping into a booking. Reading the published slot times on a page is fine. Clicking through to reserve or hold a slot, even for a second, removes that table from real diners and can trigger no-show penalties for the restaurant and abuse enforcement against you. Read slots; never book them.
- Honor
robots.txt, rate-limit hard, and back off on errors so you never degrade the service for the people actually trying to book dinner.
This is engineering guidance, not legal advice. If the data feeds a commercial product, run the plan past a lawyer first.
What data you can extract (fields reference)
An OpenTable restaurant page carries most of what you want inside one embedded JSON-LD block, plus availability that lives somewhere else entirely. The search results page is thinner: it is a list of cards linking to restaurant profiles, so the durable pattern is to harvest restaurant slugs from search, then follow each one for the full record. Here is the reference set worth pulling, with the stable way to get each field as of mid-2026.
| Field | Where it lives | How to get it | Notes |
|---|---|---|---|
| Restaurant name | JSON-LD `name`; page `h1` | Parse the `application/ld+json` block | JSON-LD is the most stable source |
| Restaurant slug | canonical URL `/r/ | `link[rel="canonical"]` or the `/r/` href | Your stable per-restaurant identifier |
| Rating | JSON-LD `aggregateRating.ratingValue` | float, 1.0 to 5.0 | OpenTable rates on a 5-point scale |
| Review count | JSON-LD `aggregateRating.reviewCount` | integer | Matches the "N reviews" on the page |
| Cuisine | JSON-LD `servesCuisine` | e.g. "Italian", "Steakhouse" | Sometimes a list |
| Price band | JSON-LD `priceRange` | `$` to `$$$$` | OpenTable maps this to its own tiers |
| Address | JSON-LD `address` (PostalAddress) | street, locality, region, postal code | Cleanly split into fields |
| Phone | JSON-LD `telephone` | formatted string | Not always present |
| Availability (time slots) | GraphQL `/dapi/fe/gql` | render the page, then read slot buttons | NOT in the JSON-LD or initial HTML |
| Reviews (sample) | JSON-LD `review[]` | first few reviews | Full set via `?page=` pagination |
Two rows explain the whole shape of this job. The rating, review count, cuisine, price, and address are handed to you already parsed inside the block, so you never chase generated class names for any of them. Availability is the opposite: it is not in the page source at all, because OpenTable fetches it live from a GraphQL API after the page loads. That single split decides your whole architecture, so it is worth understanding why.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Why OpenTable is hard to scrape
OpenTable punishes naive scrapers in ways a static restaurant page never would. Four things trip people up.
It is a JavaScript app. OpenTable's front end is React with an Apollo GraphQL data layer. A plain requests.get() returns a shell and the interactive parts paint after load. The saving grace is that the JSON-LD block and the first page of reviews are server-rendered into the initial HTML, so once you are past the bot wall, the static fields parse without a browser. Availability is the exception.
Availability is a client-side GraphQL call. When you pick a date, time, and party size, the page POSTs to https://www.opentable.com/dapi/fe/gql?opname=RestaurantsAvailability and renders the returned slots. That request carries a CSRF token pulled from the page and an Apollo persisted-query SHA256 hash that identifies the operation. OpenTable rotates those hashes between deployments, which is why a scraper that replays the raw call works one week and 400s the next. Rendering the page sidesteps that entirely, because the browser fires the current call with the current hash for you.
Commercial bot management. OpenTable fronts its pages with enterprise bot defense and per-IP rate limits. Hit it from a datacenter IP or an obvious headless client and you draw a challenge or an empty 403 instead of the listing. Residential IPs plus a genuine browser fingerprint are what get you the actual page.
Class names are generated and rotate. The rating span, the review card, the slot button: their CSS classes are hashed strings that OpenTable reshuffles on deploy. Hard-code them and your parser dies silently. Anchor instead on things OpenTable cannot freely scramble: the JSON-LD block (a web standard), link[rel="canonical"], /r/ hrefs, and data-test attributes where they exist.
| Signal | What you'll see | How to handle it |
|---|---|---|
| Bot challenge / 403 | interstitial or empty body | Residential IPs plus a real rendered browser |
| Empty slot area | availability never painted | `render_js=true` so the GraphQL call fires |
| GraphQL 400 on replay | persisted-query hash rotated | Render the page instead of replaying the XHR |
| Wrong currency / region | prices for another market | Match `country_code` to the target metro |
| Selector returns nothing | class name rotated | Anchor on JSON-LD, canonical, `/r/` hrefs |
A managed scraping API absorbs the rendering, the residential rotation, and the bot-management problem for you. The GraphQL quirk and the JSON parsing stay yours, because they live in OpenTable's page logic. For the proxy-side theory behind staying unblocked, How to Avoid Getting Your Proxy Blocked goes deep.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL, runs it through a headless browser on a rotating proxy, and returns the rendered HTML. For OpenTable, three parameters carry the weight:
render_js=true: OpenTable is a React app and availability is a live GraphQL call, so let the browser paint the page and fire that call. This is the parameter that turns an empty slot area into real data.premium_proxy=true: routes through residential IPs. Datacenter IPs draw challenges fast on OpenTable, so this decides whether you get data or an interstitial.country_code: the ISO alpha-2 exit country. Set it toUSfor US metros so the exit IP, the currency, and the content agree.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.opentable.com/r/gary-danko-san-francisco" \
--data-urlencode "render_js=true" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "country_code=US"
The full parameter list and response fields live in the Scraping API docs. The rest of the code in this guide reuses one small helper. Note the generous timeout: a rendered OpenTable page with availability can take several seconds.
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch(url, wait_for=None, wait=0):
params = {
"url": url,
"render_js": "true",
"premium_proxy": "true",
"country_code": "US",
}
if wait_for:
params["wait_for"] = wait_for # CSS selector to wait for
if wait:
params["wait"] = str(wait) # extra seconds after load
resp = requests.get(
API, headers={"X-API-Key": API_KEY}, params=params, timeout=180,
)
resp.raise_for_status()
return resp.text
Search restaurants by metro
OpenTable's search results page lives at /s, and its URL is built from a handful of query parameters. The ones that matter for coverage are metroId (a numeric identifier for a metro area), term (a cuisine or restaurant name), covers (party size), dateTime (an ISO timestamp), and page (the offset, 1-based):
https://www.opentable.com/s?metroId=<id>&term=<query>&covers=2&dateTime=<iso>&page=1
The one value you cannot guess is metroId. Grab it once per city from the metro's browse page (for example the New York landing page), where it appears in the page's inline JSON, then reuse it. Build the search URL from the parts you control:
from urllib.parse import urlencode
def search_url(metro_id, term, dt, covers=2, page=1):
q = urlencode({
"metroId": metro_id,
"term": term,
"covers": covers,
"dateTime": dt, # e.g. "2026-08-15T19:00:00"
"page": page,
})
return f"https://www.opentable.com/s?{q}"
html = fetch(search_url(metro_id=8, term="steakhouse", dt="2026-08-15T19:00:00"))
The result cards are React-hydrated, but each card links to a restaurant profile at /r/, and those links are present in the rendered HTML. That is all you need from this surface. The card also shows a rating and a price band, but they sit in generated classes that rotate, so the reliable move is to treat search as a source of restaurant slugs and pull the structured fields from each profile, where the JSON-LD lives. Use selectolax, a fast C-backed HTML parser (pip install selectolax):
from selectolax.parser import HTMLParser
def parse_search_slugs(html):
tree = HTMLParser(html)
seen, rows = set(), []
for a in tree.css('a[href*="/r/"]'):
href = a.attributes.get("href", "")
# normalize "/r/gary-danko-san-francisco?..." -> "gary-danko-san-francisco"
slug = href.split("/r/", 1)[1].split("?", 1)[0].strip("/")
name = (a.text() or "").strip()
if not slug or slug in seen:
continue
seen.add(slug)
rows.append({"slug": slug, "name": name})
return rows
OpenTable's search paginates rather than infinite-scrolling, so increment page until a request returns no new cards. Like most marketplaces, a single search caps out well short of every restaurant in a large metro, so cover a city by running several narrower term queries (by cuisine or neighborhood) and deduping on slug.
Extract ratings from the restaurant JSON-LD
Here is the payoff for anchoring on standards. OpenTable embeds a block on each restaurant page describing a schema.org Restaurant, and that block carries the rating, review count, cuisine, price, address, and phone already parsed. Read it once and you are done with class names for the static fields.
import json
def json_ld_blocks(html):
"""Yield every parsed application/ld+json object on the page."""
tree = HTMLParser(html)
for node in tree.css('script[type="application/ld+json"]'):
try:
data = json.loads(node.text())
except (json.JSONDecodeError, ValueError):
continue
# A block can be a single object or a list of them.
yield from (data if isinstance(data, list) else [data])
def restaurant_ld(html):
"""Return the Restaurant JSON-LD object, or None."""
wanted = {"Restaurant", "FoodEstablishment", "LocalBusiness"}
for obj in json_ld_blocks(html):
t = obj.get("@type", "")
types = t if isinstance(t, list) else [t]
if wanted.intersection(types) or "aggregateRating" in obj:
return obj
return None
Flatten the object into a clean record. The rating and review count sit under aggregateRating, exactly where schema.org defines them:
def restaurant_record(html, slug):
obj = restaurant_ld(html)
if not obj:
return None
addr = obj.get("address", {}) or {}
rating = obj.get("aggregateRating", {}) or {}
cuisine = obj.get("servesCuisine")
return {
"slug": slug,
"name": obj.get("name"),
"cuisine": ", ".join(cuisine) if isinstance(cuisine, list) else cuisine,
"price_range": obj.get("priceRange"),
"phone": obj.get("telephone"),
"rating": rating.get("ratingValue"),
"review_count": rating.get("reviewCount"),
"street": addr.get("streetAddress"),
"city": addr.get("addressLocality"),
"region": addr.get("addressRegion"),
"postal_code": addr.get("postalCode"),
"url": f"https://www.opentable.com/r/{slug}",
}
html = fetch("https://www.opentable.com/r/gary-danko-san-francisco")
record = restaurant_record(html, "gary-danko-san-francisco")
This is the same durable pattern behind How to Scrape Yelp Data: read the schema.org block, not the styled DOM. If the block is missing on a given page (it happens on a few legacy / URLs), fall back to the newer /r/ form of the same restaurant, which is the one OpenTable server-renders fully.
Scrape reservation availability (the GraphQL time-slot API)
This is the field competitors' guides skip, because it is the one that actually takes thought. Availability is not in the page source. OpenTable fetches it after load by POSTing to its GraphQL endpoint:
POST https://www.opentable.com/dapi/fe/gql?opname=RestaurantsAvailability
Content-Type: application/json
The body carries the restaurant ID, the party size, and the date and time, plus an extensions.persistedQuery.sha256Hash identifying the operation, and the request needs a CSRF token header lifted from the page. You have two ways to get the slots, and the durable one is not the obvious one.
Path A: render the page and read the slots (recommended)
OpenTable's profile URL accepts the reservation parameters directly, so you can preselect a date, time, and party size in the URL and let the browser do the GraphQL call for you. Then you read the rendered slot buttons from the DOM. No token, no persisted-query hash, nothing to keep in sync with OpenTable's next deploy.
def availability_url(slug, dt, covers=2):
q = urlencode({"dateTime": dt, "covers": covers})
return f"https://www.opentable.com/r/{slug}?{q}"
# Wait for the slot buttons to paint before capturing the HTML.
url = availability_url("gary-danko-san-francisco", "2026-08-15T19:00:00", covers=2)
html = fetch(url, wait_for='[data-test="time-slot"]', wait=2)
Parse the slots. Anchor on the data-test attribute where OpenTable exposes one, and keep a time-text regex as a fallback so a class rename does not blank your results. Both are current as of 2026 and both are hedged on purpose:
import re
TIME_RE = re.compile(r"\b(1[0-2]|0?[1-9]):[0-5][0-9]\s?(?:AM|PM)\b", re.I)
def parse_slots(html):
"""Read the visible reservation time slots. data-test first,
time-text fallback second, because class names churn."""
tree = HTMLParser(html)
slots = []
nodes = tree.css('[data-test="time-slot"]') or tree.css("a, button")
for n in nodes:
label = (n.attributes.get("aria-label") or n.text() or "").strip()
m = TIME_RE.search(label)
if m:
slots.append(m.group(0).upper().replace(" ", ""))
# de-dupe while keeping order
return list(dict.fromkeys(slots))
open_slots = parse_slots(html) # e.g. ["6:30PM", "7:00PM", "9:15PM"]
If a restaurant hides slots behind a party-size or date picker that the URL parameters do not preselect, drive the widget with a js_scenario (click the picker, wait for the slots) instead of reading the static render. The action keys for click and wait are documented in the Scraping API docs; point to the docs rather than hard-coding them, since the widget markup can change.
Path B: replay the GraphQL call (advanced, brittle)
If you need availability across thousands of restaurants and rendering each one is too slow, you can call the GraphQL endpoint directly. Apollo persisted queries can be issued as a GET with variables and extensions in the query string, so you can point the Scraping API at the endpoint, forward the CSRF token as a header, and skip rendering (render_js=false) because the response is JSON, not a page:
def availability_via_gql(variables, extensions, csrf_token):
endpoint = "https://www.opentable.com/dapi/fe/gql?opname=RestaurantsAvailability"
target = f"{endpoint}&variables={json.dumps(variables)}&extensions={json.dumps(extensions)}"
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": target,
"render_js": "false",
"premium_proxy": "true",
"country_code": "US",
"format": "json",
# forward the token OpenTable expects on the GraphQL call
"forward_headers": json.dumps({"x-csrf-token": csrf_token}),
},
timeout=120,
)
resp.raise_for_status()
return resp.json()
Two values here are moving targets. The sha256Hash inside extensions is the persisted-query id, and OpenTable rotates it between deployments, so you have to scrape the current hash from the page's script bundle each run. The x-csrf-token is minted per session and lifted from the profile page's inline state. When either drifts, the call 400s. That fragility is the whole argument for Path A: rendering never asks you to track OpenTable's internal hashes. Treat Path B as a speed optimization you verify against a live page and expect to repair, and lean on How to Scrape GraphQL APIs and How to Scrape Hidden JSON API Endpoints for the general technique.
One hard line worth repeating: read the slots, never book them. Availability data is public; a reservation is a real table, and holding one you will not honor harms the restaurant.
Scrape OpenTable reviews with pagination
OpenTable seeds the restaurant page's JSON-LD with a sample of reviews, usually the first few. That is the quick win:
def parse_reviews_ld(html):
"""OpenTable seeds JSON-LD with a sample of reviews."""
obj = restaurant_ld(html) or {}
out = []
for r in obj.get("review", []) or []:
rating = r.get("reviewRating", {}) or {}
author = r.get("author")
out.append({
"author": author.get("name") if isinstance(author, dict) else author,
"rating": rating.get("ratingValue"),
"date": r.get("datePublished"),
"text": r.get("reviewBody") or r.get("description"),
})
return out
To get the full set, page the profile with the page query parameter. OpenTable serves reviews roughly 10 to a page, 1-based, and re-renders each page into the HTML, so you reuse the parser above per page and loop until a page comes back empty or you reach the review count from the record:
import time
def scrape_all_reviews(slug, review_count):
reviews, page = [], 1
while len(reviews) < review_count:
url = f"https://www.opentable.com/r/{slug}?page={page}"
html = fetch(url)
batch = parse_reviews_ld(html)
if not batch:
break # blocked, or ran out of reviews
reviews.extend(batch)
page += 1
time.sleep(1.5) # be polite; back off harder on errors
return reviews
There is a detail here that makes OpenTable reviews more valuable than most, and it is worth building your analysis around. OpenTable reviews come only from verified diners: you cannot review a restaurant unless you actually booked and completed a reservation through OpenTable. That kills the drive-by and competitor-sabotage reviews that pollute open platforms, so the ratings carry less noise. OpenTable reviews also break the score into sub-ratings (food, service, ambience) and often a noise level, so where a Yelp record gives you one number, an OpenTable record gives you a small vector. Pull the sub-ratings when they are present and you can tell a "great food, slow service" restaurant apart from a "fine food, flawless service" one, which is the kind of segmentation that actually drives decisions.
Reviews are personal data. A name, a rating, and a dining date together identify a real person under GDPR and CCPA, so store the aggregate rating and counts freely, and be deliberate about whether you keep reviewer identities and full text. If you are tracking sentiment over time, Using Proxies for Review Monitoring and Sentiment Analysis covers the analysis side, and How to Scrape TripAdvisor Reviews shows the same pattern on a neighboring target if you are building a cross-platform view.
Scale without getting blocked
At volume, a few habits keep your run clean and your data complete:
- Rate-limit and back off. A short sleep between requests plus exponential backoff on soft blocks does more for your success rate than any clever trick. Detect the challenge or empty page and retry instead of saving it as data:
BLOCK_MARKERS = ("access denied", "verify you are a human",
"unusual activity", "request unsuccessful")
def is_blocked(html):
low = html.lower()
return len(html) < 2000 or any(m in low for m in BLOCK_MARKERS)
- Split availability from static fields. Rating, reviews, and address change slowly, so scrape them on a slow cadence. Availability changes by the minute, so fetch it only for the date and party size you actually care about, close to when you need it. Rendering every restaurant for every possible time is wasted spend.
- Keep concurrency modest. With a scraping API the provider rotates the exit IP per request, so your ceiling is your plan's rate limit rather than a pool you babysit. Steady and moderate beats spiky.
- Match the region. Keep
country_code=USfor US metros so the exit IP, the currency, and the content agree, which also lowers the odds of an interstitial. - Persist as you go. Write each restaurant and its reviews to storage as they land, so a mid-run block never costs you the batch.
The general high-volume patterns (concurrency, retries, rotation) are covered in Using Datacenter Proxies for Web Scraping. For OpenTable specifically, residential exits do the heavy lifting past the bot wall, the JSON-LD keeps your static parser alive across front-end deploys, and rendering the page is what keeps availability working when OpenTable rotates its GraphQL hashes.
Frequently asked questions
FAQ
Scraping publicly visible restaurant profiles, ratings, and reviews (no login) generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but it can still breach OpenTable's Terms of Use and robots.txt, which prohibit automated collection. Stick to public data, treat reviewer identities as personal data under GDPR and CCPA, never convert availability scraping into an actual booking, rate-limit so you do not degrade the service, and get legal advice before any commercial use.
No. OpenTable retired its old affiliate and partner data API, and the booking integration that remains is restricted to its restaurant partners, not an open feed you can request as an outside analyst. That absence is why people scrape the public pages to extract OpenTable restaurant data for research and market analysis.
Availability is loaded live from OpenTable's GraphQL endpoint (/dapi/fe/gql), so it is not in the page source. The durable way to get it is to render the profile URL with the date and party size preselected (?dateTime=...&covers=2) using render_js=true, wait for the slot buttons to paint, and read them from the DOM. That lets OpenTable's own JavaScript make the GraphQL call, so you never have to track its rotating persisted-query hash.
OpenTable is a React app whose availability and interactive data arrive from a GraphQL API after the initial HTML loads. Without rendering, the slot area is empty and much of the page is a shell. Setting render_js=true runs a real browser that fires those calls and paints the data, which is also what clears the headless-client checks in OpenTable's bot management.
Yes. OpenTable seeds the first few reviews into the page's JSON-LD, and the rest paginate with a ?page=N parameter serving about 10 per page, so you loop until a page returns empty or you reach the review count. Remember that OpenTable reviews are verified-diner reviews (only people who completed a reservation can post), which makes them cleaner signal, but the reviewer name plus dining date is personal data, so be deliberate about what you store.
Two causes dominate. Datacenter IPs draw OpenTable's bot-management challenges, so route through residential IPs with premium_proxy=true. And an empty slot area usually means JavaScript never ran, so set render_js=true so the availability GraphQL call fires. Detect the challenge or short-body page in code and retry rather than saving it as data.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.

How to Scrape Yandex Search Results in 2026
Scrape Yandex search results reliably: lr region codes, Cyrillic queries, SmartCaptcha detection, the official XML API, and working SparkProxy code.

How to Scrape Vinted Listings
Scrape Vinted listings across vinted.fr, .de and .co.uk: the internal JSON API, cookie bootstrapping, per-market catalogue IDs, and GDPR-safe resale analytics.
