How to Scrape JSON API Endpoints Behind a Site
Scrape JSON API endpoints hidden behind dynamic pages: find the XHR call in DevTools, replay it with the right headers and tokens, and skip HTML rendering.

Most "dynamic" sites you want to scrape are not actually dynamic on the server. The page ships an empty shell, then JavaScript calls a private JSON endpoint and paints the result. If you learn to scrape JSON API responses directly instead of rendering that HTML, you get cleaner data, faster requests, and a bill that drops by roughly 5x. This guide shows you how to find the hidden endpoint in DevTools, read the request that fires it, replay it in Python with the right headers and tokens, page through the whole dataset, and run the whole thing through the SparkProxy Scraping API without paying for a browser you don't need.
Why Scrape the JSON API Instead of the HTML
When a product grid, a review list, or an infinite-scroll feed loads after the page appears, that data almost always arrives over an XHR or fetch call returning JSON. Parsing the rendered HTML means running a headless browser, waiting for the DOM to settle, then writing CSS or XPath selectors against markup that changes every redesign. Hitting the JSON endpoint skips all of that. You ask the same URL the browser asks, and you get back structured, typed fields.
The difference is not cosmetic. Here is the trade-off, dimension by dimension:
| Dimension | Scrape the rendered HTML | Hit the JSON API |
|---|---|---|
| Speed | Full browser render (JS, CSS, fonts, images) | One small JSON round trip |
| Parsing | Brittle CSS/XPath against changing markup | Stable keys in a typed object |
| Data completeness | Only what the UI paints on screen | Often more fields than the UI shows |
| Cost via Scraping API | `render_js=true`, 5 credits | `render_js=false`, 1 credit |
| Breaks when | The site gets a visual redesign | The API version actually changes |
| Pagination | Simulate clicks and scrolls | Change a `page`, `offset`, or `cursor` value |
The completeness point is the one people underestimate. A product card might show a rounded price and a star rating, while the JSON behind it carries the raw price to four decimals, the internal SKU, stock counts per warehouse, and a sponsored flag the UI never renders. You are reading the same feed the front end reads, so you get everything the front end was handed. If you are new to the broader discipline, our primer on what web scraping is covers the vocabulary used below.
There is a boundary worth naming up front. This technique targets the exact endpoints the site's own front end calls in your normal browser. It is not credential theft or bypassing access controls. Scrape public data you can already see, respect the site's terms and robots.txt, and stay away from personal or authentication-gated data you have no right to.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Read the Request You Found
Before writing any code, let the browser hand you a working command. Right-click the request and choose Copy > Copy as cURL (bash). You get the full call with every header and cookie the browser sent:
curl 'https://www.sparkproxy.io/api/v2/products?category=proxies&page=1&limit=50' \
-H 'accept: application/json' \
-H 'referer: https://www.sparkproxy.io/products' \
-H 'x-requested-with: XMLHttpRequest' \
-H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
Run that in a terminal. If it returns the same JSON you saw in DevTools, you have a reproducible request and the rest is cleanup. Now figure out which headers actually matter, because copying all of them verbatim into production is noisy and fragile. Most hidden APIs care about a small subset:
accept: application/jsontells the server to return JSON rather than an HTML fallback. Many endpoints gate on this.x-requested-with: XMLHttpRequestis a common AJAX marker that some frameworks require.refererandoriginare checked by APIs that refuse calls not coming from their own pages.- An auth header (
authorization: Bearer ...,x-api-key: ...) or a session cookie, when the data is account-scoped.
Strip the request down to those, test again, and keep only what breaks the call when removed. Paste the cURL into a converter, or use curl_cffi if the site fingerprints TLS, but for most JSON endpoints plain requests is enough.
Replay the Endpoint and Scrape JSON API Data in Python
With the minimal header set known, the replay is short. This is the core loop for any endpoint that needs no login:
import requests
resp = requests.get(
"https://www.sparkproxy.io/api/v2/products",
params={"category": "proxies", "page": 1, "limit": 50},
headers={
"Accept": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Referer": "https://www.sparkproxy.io/products",
"X-Requested-With": "XMLHttpRequest",
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
for item in data["items"]:
print(item["name"], item["price"], item["sku"])
Passing the query parameters as a params dict instead of hard-coding them into the URL is not a style preference. It makes pagination and filtering trivial, because you change one value in the dict rather than rebuilding a string. resp.raise_for_status() turns a silent 403 into a loud exception so you notice an auth problem immediately instead of parsing an error page as if it were data.
When you scale this from one call to thousands, the network layer becomes the bottleneck. Running these requests concurrently with a proxy pool is its own topic, covered in our guide on async scraping with requests and aiohttp. The endpoint you found here plugs straight into that concurrency model, since each call is independent and stateless.
Paginate Through the Full Dataset
A single response is a slice. To scrape the whole dataset you need to walk the API's pagination, and hidden APIs use one of three shapes. Read the request's query params and the response body to tell which.
Offset and limit. The request carries offset and limit (or skip and take). Increment the offset by the page size until a page comes back short:
def paginate_offset(url, base_params, headers, page_size=50):
offset = 0
while True:
params = {**base_params, "offset": offset, "limit": page_size}
rows = requests.get(url, params=params, headers=headers, timeout=30).json()["items"]
if not rows:
break
yield from rows
offset += page_size
Page numbers. The request has page=1, and the response usually reports total_pages or a has_more flag. Loop the page number and stop when the response says there is nothing left. This is the friendliest shape to reason about.
Cursors. The response returns a next_cursor or next token, and you feed it back on the following call until it comes back null. Cursor pagination is common on large or fast-changing feeds because it is stable while data shifts:
def paginate_cursor(url, headers, page_size=100):
cursor = None
while True:
params = {"limit": page_size}
if cursor:
params["cursor"] = cursor
data = requests.get(url, params=params, headers=headers, timeout=30).json()
yield from data["results"]
cursor = data.get("next_cursor")
if not cursor:
break
Two habits keep you out of trouble. Do not hammer every page back to back; add a short delay so you look like a client, not a firehose. And cap your loop, because a bug in the stop condition against a cursor API can spin forever. A max_pages guard is cheap insurance.
When the Endpoint Is Signed or Rate-Limited
Not every hidden API surrenders to a copied cURL. Two defenses show up on hardened targets, and each has a clear answer.
Client-computed signatures. Some endpoints require a header like x-sign or x-signature, computed in the site's JavaScript from the query params, a timestamp, and a secret baked into the bundle. Replaying a captured signature works for a few seconds, then the timestamp expires and every call returns 401. You have two options. Reverse the signing function out of the minified JS and reimplement it, which is precise but breaks whenever they ship a new bundle. Or stop fighting it: load the page in a real headless browser and capture the XHR response the browser generates with a valid signature. When reversing the crypto would cost you a day and it changes monthly, capturing the call is the cheaper engineering decision. That is exactly the moment a browser render earns its extra cost.
Rate limits and IP checks. Private APIs often enforce stricter limits than the public site, and some reject datacenter IP ranges or check Origin on every call. Respect the Retry-After header when you get a 429, add jitter between requests, and spread traffic across rotating IPs. For targets that block datacenter ranges outright, route through residential exits. The full playbook for staying under detection thresholds is in how to avoid getting your proxy blocked.
Scrape JSON APIs via the SparkProxy Scraping API
Once you know the endpoint, headers, and pagination, the only thing left is delivering the request from an IP the target trusts, at scale, without maintaining a proxy pool yourself. That is what the SparkProxy Scraping API does. The key setting for this technique is render_js.
Because you are calling a JSON endpoint, no browser is involved, so you set render_js=false. That single flag drops the cost from 5 credits to 1 and runs roughly 3x faster, since the API does a plain HTTP fetch instead of spinning up Chromium. Most people leave rendering on by default and overpay 5x for pages that never needed a browser. You forward the headers the endpoint expects, geo-target the exit, and read the JSON back:
import os
import requests
r = requests.post(
"https://scrape.sparkproxy.io/api/v1",
headers={
"X-API-Key": os.environ["SPARKPROXY_API_KEY"], # key format: sk-...
"Content-Type": "application/json",
},
json={
"url": "https://www.sparkproxy.io/api/v2/products?category=proxies&page=1&limit=50",
"render_js": False, # 1 credit: a JSON endpoint needs no browser
"premium_proxy": True, # residential exit for strict Origin/IP checks
"country_code": "us", # geo-target the exit IP
"forward_headers": {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
},
timeout=60,
)
r.raise_for_status()
data = r.json() # the target endpoint's JSON, fetched through a fresh proxy IP
forward_headers passes your Accept, X-Requested-With, and any Authorization header straight through to the target. premium_proxy upgrades to residential IPs when the API rejects datacenter ranges, and country_code fixes the exit geography for region-locked data. Because the API assigns a fresh IP per call, the rotation and retry logic you would otherwise hand-roll is handled for you. Keep the split practical: send high-volume, simple JSON endpoints through render_js=false for a single credit each, and reserve the browser render for the signed endpoints from the previous section. If you are weighing a managed API against building this yourself, the trade-offs are laid out in web scraping API vs self-managed proxies.
Common XHR Scraping Errors and Fixes
| Symptom | Cause | Fix |
|---|---|---|
| Works in browser, `403` from Python | Missing cookie or auth header | Carry cookies with a `Session`; copy the `Authorization` header |
| `403` even with a valid token | Missing `Referer`, `Origin`, or `X-Requested-With` | Add the exact headers DevTools shows on the request |
| HTML returned instead of JSON | Endpoint gates on the `Accept` header | Send `Accept: application/json` |
| `419` / `422` CSRF token mismatch | Double-submit cookie not echoed | Read the `XSRF-TOKEN` cookie, send it as `X-XSRF-TOKEN` |
| `401` after a few seconds | Request needs a fresh signed timestamp | Capture the XHR from a headless render instead |
| `429 Too Many Requests` | API limit stricter than the site | Respect `Retry-After`, rotate IPs, add jitter |
| Blocked only at scale | Datacenter IP flagged by the API | Route through residential (`premium_proxy`) or a target country |
| Empty `items` on page 2+ | Wrong pagination shape | Check whether it uses offset, page, or cursor |
Frequently asked questions
FAQ
Open DevTools, go to the Network tab, and filter to Fetch/XHR. Reload or trigger the action that loads the data, then use the Network search box (Ctrl+F inside the panel) to search response bodies for a value you can see on the page, such as a product name. The request whose response contains it is the endpoint. That is the core move behind finding website API endpoints.
The same rules apply as scraping the rendered HTML. Collecting public data you can already view is generally fine, but you should respect the site's terms of service and robots.txt, avoid personal or authentication-gated data you have no right to, and keep your request rate reasonable. This is general guidance, not legal advice, so check the specific target's terms.
Usually no. If you can replay the request with plain HTTP and the right headers, you skip the browser entirely, which is the whole point of hidden API scraping. You only need a headless browser when the endpoint requires a signature computed in client-side JavaScript, in which case you render the page and capture the XHR response the browser generates.
Hitting the JSON endpoint is faster, cleaner, and cheaper. You get typed fields instead of brittle CSS selectors, you often receive more data than the UI displays, and through the SparkProxy Scraping API you pay 1 credit with render_js=false instead of 5 for a full render. The endpoint also breaks far less often, since it only changes on an API version bump, not a visual redesign.
Read the request parameters and the response body to identify the shape. Offset APIs increment offset by the page size until a page returns empty, page-number APIs loop page until has_more is false, and cursor APIs feed the returned next_cursor back on the next call until it is null. Always add a delay between pages and a max_pages cap to avoid runaway loops.
Start from the request DevTools captured, then keep only the headers that break the call when removed. In practice that is usually Accept: application/json, often X-Requested-With: XMLHttpRequest, sometimes Referer and Origin, and an Authorization header or session cookie when the data is account-scoped.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Scrape Airbnb Listings and Prices
Learn how to scrape Airbnb listings and prices: extract fields from Airbnb's embedded JSON, handle date-based pricing, map pagination, and anti-bot defenses.

How to Scrape GraphQL APIs
Learn to scrape GraphQL API data: find the /graphql endpoint, read the query and variables in DevTools, then replay your own queries with cursor pagination.

How to Bypass reCAPTCHA When Web Scraping
How to bypass reCAPTCHA when web scraping the ethical way: how v2 and v3 scoring work, how to raise your reCAPTCHA score, and solvers as a last resort.
