๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

How to Scrape Temu Product Data

How to scrape Temu product data in 2026: what the product HTML really holds, why the oak render API answers NEED_LOGIN, and an escalation path that works.

S SparkProxy 4 27 min read
Share

Most guides on how to scrape Temu product data show you a requests.get() and a BeautifulSoup selector for the price. That code cannot work, because the price is not in the HTML. We spent an hour probing temu.com on 9 October 2026 from one IP with curl, curl_cffi 0.16.3 and Playwright Chromium, and the product page returns HTTP 200 with a server-rendered shell that contains no price, no rel=canonical, no ld+json and no product object at all. This guide documents what the pages actually serve, where the JSON really lives, which defense fires at each step, and a three-rung escalation that ends in a managed path. Two of those steps a stateless HTTP client cannot do at all, and the post says which.

What we measured, and when

Everything below was reproduced on 2026-10-09 between 05:04 and 05:15 UTC from a single residential-grade IP, against www.temu.com. The storefront that answered was Canada (region=37, currency=CAD), because the exit IP sat in a country Temu does not list. Tooling: curl 8.x, curl_cffi 0.16.3 with impersonate="chrome", and Playwright Chromium in headless mode. Temu ships behind Cloudflare plus its own gateway, so your numbers will differ by IP reputation and by day. The shapes will not.

The single most useful thing we saw has nothing to do with selectors. The first request to https://www.temu.com/ from a cold IP returned 635,875 bytes containing window.__SSR__=1, window.__ServerRenderSuccess__=true and a window.rawData object holding ten products complete with ids, titles, prices, units sold and ratings. Roughly fifty requests and ten minutes later, the same URL returned 73,384 bytes with zero goodsId and no rawData key whatsoever. Same status code, same content type, no captcha, no error string, no redirect. The data just stopped.

That is the real failure mode. Your scraper does not crash on Temu, it quietly starts writing empty rows, and if your monitoring only checks for HTTP 200 you will not find out for a week.

Where Temu keeps product data by page type

Temu's web app is a single React application whose route is declared in the HTML as window.__PageContext__, for example {"pagePath":"w/goods","buildId":"y3X7j_1791461234977"}. Every page we hit also set window.__DOC_SOURCE__ = "ssr", so Temu is genuinely server-rendering. The question is what it renders into. Here is what each page type actually returned:

URL pattern`pagePath``__DOC_SOURCE__``window.rawData`Product fields present
`/` (cold IP, first hit)`w/index``ssr`yes10 priced items: id, title, price, sold, rating
`/` (same IP, ~50 requests later)`w/index``ssr`nonone
`/ca/channel/best-sellers.html``w/channel/best-sellers``ssr`nonone (929 KB of shell)
`/ca/channel/lightning-deals.html``w/channel/lightning-deals``ssr`nonone (946 KB of shell)
`/g-.html``w/goods``ssr`nonone
`/search_result.html?search_key=...``w/search_result``ssr`nonone
`/search_result.html?...` with an iPhone UA`w/search_result``ssr`yes`goodsList` present but empty
`/sitemap-index.xml`n/an/an/aHTTP 403, zero-length body

Read the fourth column against the fifth. rawData existing is not the same as rawData carrying products. The homepage rail is the only place we ever saw real product JSON arrive over plain HTTP, and the mobile search page proves the pattern: it serialized a full search store including a telling key named cacheErrorGoodsList, with goodsList as an empty array.

Note the sizes too. curl with a Chrome user agent pulled 79,923 bytes for a product page; curl_cffi with a real Chrome TLS fingerprint pulled 316,935 bytes for the same URL. Bigger, and equally useless: both shells had zero prices. A larger response is not a better response.

Two more things that look like data and are not. The homepage carries three application/ld+json blocks, and in the live markup their text is partially replaced with asterisks: "connects consumers with of merchandise , manufacturers and brands with to them". Product pages carry no ld+json at all. And /sitemap.xml is not a sitemap; it returns the homepage HTML with HTTP 200, while the real Sitemap: line in robots.txt points at /sitemap-index.xml, which 403s.

If you have scraped the neighbors, the contrast is the useful part. AliExpress hands you the entire product, price, SKU and seller payload in one window.runParams blob, which is why scraping AliExpress product data is mostly a JSON-path exercise. Shopee and Lazada expose numbered REST endpoints you can call directly, as scraping Shopee and Lazada data documents. Temu does neither. It is the strictest of the three by a wide margin, so budget for it like a bot-managed target rather than an e-commerce site.

# Reproduce the page-type matrix. Watch size AND content, never status alone.
for u in "https://www.temu.com/" \
         "https://www.temu.com/g-607575047260272.html" \
         "https://www.temu.com/search_result.html?search_key=wireless%20earbuds"; do
  curl -s --compressed -o /tmp/p.html -w "%{http_code} %{size_download} " \
    -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' \
    -H 'Accept-Language: en-US,en;q=0.9' "$u"
  echo "rawData=$(grep -c 'window.rawData' /tmp/p.html) goodsId=$(grep -o '"goodsId"' /tmp/p.html | wc -l) $u"
done
Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The JSON endpoint: /api/oak/integration/render

There is a JSON endpoint, and it is not GraphQL. Temu's SSR layer leaves its own call log in the HTML, inside window.__MONITOR_INFOS__.serverData.marks, and the product page's log names the routes it hit server-side: api/oak/integration/render, api/poppy/v1/opt, api/poppy/v1/title_bar_list, api/seo/get_common_biz_data, api/alexa/pc/homepage/activity. Grep for them yourself:

curl -s --compressed 'https://www.temu.com/g-607575047260272.html' \
  -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' \
| grep -o 'api_api/[a-z0-9/_]*#' | sort -u

POST /api/oak/integration/render is the one that carries the product. Playwright's request log captured its exact body, which is a flat object of goods_id plus feature flags:

{
  "goods_id": "607575047260272",
  "_oak_query_app_only": 1,
  "_oak_query_used_wish": 1,
  "_oak_show_leaf_category_low_price_tag": 1,
  "_oak_show_sale_price_suffix": 1,
  "_oak_show_what_rrp": 1,
  "select_address": {}
}

Call it from a script and you get a precise, honest refusal:

curl -s -o - -w '\n[http %{http_code}]\n' -X POST \
  'https://www.temu.com/api/oak/integration/render' \
  -H 'Content-Type: application/json' \
  -H 'Referer: https://www.temu.com/g-607575047260272.html' \
  -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' \
  --data '{"goods_id":"607575047260272","select_address":{}}'

# {"success":false,"error_code":40001,"error_msg":"NEED_LOGIN"}
# [http 403]

We tried it with a cookie jar warmed from a real page load (api_uid, region, language, currency, __cf_bm) and got the identical 40001. Cookies are not the missing piece. The missing piece is a request header named anti-content.

In the browser trace, every request to a poppy or oak route carried anti-content, a 414 to 420 character token that looks like 0GXHp0W4MmIeijvf27ogzXoFFQTQjwKPjFpOz3lzwJtP_6.... It is produced by a family of endpoints under /api/phantom/ that run before anything else on the page: GET /api/phantom/dm/wl/cg, GET /api/phantom/xg/pfb/a3, GET /api/phantom/xg/pfb/b, POST /api/phantom/xg/pfb/l1 with the body {"version":"2.19.1"}, and POST /api/phantom/xg/pfb/a4 carrying a compressed base64 data blob. A sibling call, POST /api/bg/tampa/web_device/record, sends x-phan-data and {"action":1,"trigger_action":"bgid_change"}. That is a device-fingerprint pipeline, and anti-content is its signed output.

So yes, there is a JSON endpoint behind the page, and no, you cannot call it from Python without running Temu's own JavaScript to mint the token. If you want the general pattern for finding endpoints like this on friendlier targets, the method is in how to scrape hidden JSON API endpoints. Temu is the hard end of it.

Pagination is a server-minted cursor

There is no ?page=2. Temu paginates with an offset against a list identity that the server hands out per session. The breadcrumb and recommendation rails on a product page call POST /api/poppy/v1/opt with this body:

{
  "pageSn": 10032,
  "list_id": "pc_crumb_list_8na3w8",
  "offset": 0,
  "page_size": -1,
  "mainGoodsId": "607575047260272",
  "scene": "pc_crumb_list"
}

The search page does the same with a different key: POST /api/poppy/v2/search_activation sends {"scene":"search_activation","listId":"0658aef3a83c458f9ef832851adfab83","pageSn":10032,...}. Both list_id and listId are opaque server-issued strings tied to a session and a pageSn route number (10005 for the homepage, 10032 for a product page). You advance a result set by incrementing offset against the same list_id.

Two consequences worth planning around. A stateless client cannot invent a valid list_id, so page 2 is unreachable without first obtaining page 1 in a live session. And because the cursor is session-scoped, deep pagination is a long-lived-session problem, which is why rotating an IP mid-crawl breaks a result set instead of saving it. The session-pinning side of this is covered in session-aware proxy rotation for JavaScript-rendered sites.

The defenses, named and placed

Temu stacks six distinct mechanisms, and they fire in different places. Naming them matters, because the fix for one does nothing for another.

DefenseWhere it firesWhat you seeVerified evidence (2026-10-09)
Cloudflare bot managementTLS/edge, before Temu's app`__cf_bm` cookie, `Server: cloudflare`, `CF-RAY`Set-Cookie `__cf_bm` on every response; 403 with zero body on `/sitemap-index.xml`
Temu gateway rate controlPer path, per IPHTTP 403, empty body`/sitemap-index.xml` served 200 on the first requests from a cold IP, then 403 on every retry across 5 UAs and 3 TLS fingerprints
SSR payload strippingServer render, silentlyHTTP 200, no `window.rawData`Homepage 635,875 bytes with 10 products, then 73,384 bytes with none
JS challenge interstitialPer request, by signal2.9 KB obfuscated script, HTTP 200A Googlebot UA got a 2,911-byte challenge on `/g-...html`, 2,907 on search, 2,889 on `/`, reproducible on repeat
`anti-content` gate on the APIEvery `poppy` / `oak` call403 `NEED_LOGIN`, 429, 424`oak/integration/render` 403, `poppy/v1/opt` 403, `poppy/v2/search_activation` 429, `passport/token/touch` 424
Login wallTerminal state for a flagged IPHTTP 200, a login page titleHeadless Chromium on a product URL: `document.title` was `Temu \Login`, body text 1,601 chars, `h1` "Sign in / Register"

The challenge row inverts advice you will read elsewhere. Sending a Googlebot user agent does not get you the SEO-friendly version of a Temu page. It gets you a challenge, immediately, on every path:

curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  'https://www.temu.com/g-607575047260272.html' | wc -c
# 2911

# The body is an obfuscated loader. Its decoded string table includes:
#   'challenge', 'moytj4zg87xe2f5s', 'tcb927d8b2902734a503881c15c4362fa6'
#   'https://static.kwcdn.com/upload-static/assets/chl/js/fea85011-...-2fcd40e00205.js'
#   fallback host 'static-2.kwcdn.com', handlers 'onload' / 'onerror'

That /chl/js/ path is the challenge runtime. Two more labels from Temu's own bundles tell you how deliberate this is: window.__CRAWLER_BY_TITAN__, referenced in the page's telemetry code, and two flags inside window.rawData.store named banApiLoginError and banApiRiskError. Temu is not guessing at bot traffic as a side effect. It has named states for it.

Rung 1: plain HTTP with a correct TLS fingerprint

Start here anyway, because one job genuinely works at rung 1. The homepage rail hands a cold IP a short, fully priced product list for free, under store.homeModuleList[n].data.goodsList[] where the module's moduleName is flash_sale. Each item carried more than expected:

FieldPath on the itemExample value
Product ID`goodsId``"607575047260272"`
Title`goodsName``"6 Sheet Set of Chair Fitness Plan..."`
Display price`priceInfo.priceStr``"CA$8.37"`
Numeric price`priceInfo.price``837` (minor units)
Currency`priceInfo.currency``"CAD"` (set by the exit IP)
Units sold`salesTip` / `salesNum``"2.1K+ sold"` / `"2.1K+"`
Rating`comment.goodsScore``4.6`
Review count`comment.commentNumTips``"63"`
Image`hdThumbUrl` / `thumbUrl``img.kwcdn.com/product/fancy/...jpg`
Link`linkUrl` / `seoLinkUrl`points at the channel page, not the product

Note the last row. Neither link field gives you a product URL, so build it yourself from goodsId as https://www.temu.com/g-.html. Harvesting IDs cheaply here and pricing the ones you care about expensively later is the split that keeps the bill sane.

A plain requests.get() sends a Python TLS handshake, which is a trivially distinguishable signal regardless of your headers. Use curl_cffi so the ClientHello matches the user agent you claim. If the mismatch concept is new, what is TLS fingerprinting covers why a correct header set over the wrong handshake is worse than no spoofing at all.

# pip install curl_cffi
import json
from curl_cffi import requests

def _brace_slice(text, start):
    """window.rawData={...};document.dispatchEvent(...) - the assignment is 108 KB of
    nested JSON and does NOT end at the next '}', so a non-greedy regex will hand you
    an unparseable fragment. Count braces instead."""
    i = text.index("{", start)
    depth, k = 0, i
    while k < len(text):
        if text[k] == "{":
            depth += 1
        elif text[k] == "}":
            depth -= 1
            if depth == 0:
                return text[i:k + 1]
        k += 1
    raise ValueError("unbalanced rawData blob")

def temu_rail(url="https://www.temu.com/"):
    """Harvest the SSR rail. An empty result is the normal outcome once the exit IP is
    warm, so treat [] as 'rotate the exit', not as 'this page has no products'."""
    r = requests.get(url, impersonate="chrome", timeout=60)
    anchor = r.text.find("window.rawData=")
    if anchor < 0:
        return {"status": r.status_code, "bytes": len(r.text), "state": "stripped", "items": []}

    blob = json.loads(_brace_slice(r.text, anchor))
    items, stack = [], [blob]
    while stack:                       # walk the tree; don't hardcode homeModuleList[1]
        node = stack.pop()
        if isinstance(node, dict):
            if "goodsId" in node and "priceInfo" in node:
                price = node.get("priceInfo") or {}
                comment = node.get("comment") or {}
                items.append({
                    "goods_id":  node["goodsId"],
                    "title":     node.get("goodsName"),
                    "price_str": price.get("priceStr"),
                    "price":     price.get("price"),      # minor units: 837 == CA$8.37
                    "currency":  price.get("currency"),
                    "sold":      node.get("salesTip"),
                    "rating":    comment.get("goodsScore"),
                    "reviews":   comment.get("commentNumTips"),
                    "url":       f"https://www.temu.com/g-{node['goodsId']}.html",
                })
            stack.extend(node.values())
        elif isinstance(node, list):
            stack.extend(node)
    return {"status": r.status_code, "bytes": len(r.text),
            "state": "ok" if items else "empty-rail", "items": items}

out = temu_rail()
print(out["status"], out["bytes"], out["state"], len(out["items"]))
for it in out["items"][:3]:
    print(" ", it["goods_id"], it["price_str"], it["sold"], it["title"][:48])

On a cold exit that prints ten rows. On a warm exit it prints stripped with a 73 KB body, and the correct response is to rotate the exit IP, not to retry the same one. Rung 1 will never give you a price for a specific goodsId, and no amount of header tuning changes that, because the product page does not contain one.

Rung 2: a rendered browser

To get a price you need Temu's JavaScript to run, generate anti-content, and call oak/integration/render for you. Don't scrape the DOM for it. Intercept the response, because the JSON is richer than the rendered card and far more stable than a hashed class name.

# pip install playwright && playwright install chromium
import asyncio, json
from playwright.async_api import async_playwright

GOODS_ID = "607575047260272"

async def fetch_goods(goods_id, proxy=None):
    payload = {}
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True, proxy=proxy)
        ctx = await browser.new_context(
            user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                        "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"),
            locale="en-US", timezone_id="America/New_York",
            viewport={"width": 1366, "height": 900})
        page = await ctx.new_page()

        async def on_response(resp):
            if "/api/oak/integration/render" in resp.url:
                payload["http"] = resp.status
                if resp.status == 200:
                    payload["data"] = await resp.json()

        page.on("response", on_response)
        await page.goto(f"https://www.temu.com/g-{goods_id}.html",
                        wait_until="domcontentloaded", timeout=90_000)
        await page.wait_for_timeout(9_000)
        payload["title"] = await page.title()
        await browser.close()
    return payload

print(json.dumps(asyncio.run(fetch_goods(GOODS_ID)), indent=1)[:400])

Here is what that returned for us, reported rather than hidden. Navigation status 200. page.title() was Temu | Login. Body text: 1,601 characters. Price matches in the rendered DOM: zero. The oak/integration/render response status: 403, with anti-content present on the request. poppy/v1/title_bar_list 403, poppy/v1/opt 403, seo/get_common_biz_data 403, poppy/v2/search_activation 429, passport/token/touch 424.

The token was generated correctly and the request was still refused, which isolates the variable cleanly: by that point our exit IP had made about sixty requests and Temu had moved it into the login-wall state. Rendering fixes the token. It does not fix IP reputation. Headless detection is a separate axis again, and if you are debugging that layer rather than this one, headless browser detection is the relevant read.

The practical shape of a working rung 2, then, is one browser session per clean residential exit, warmed on the homepage before it touches a product URL, with the session held for the whole result set because list_id is session-scoped. That is a pool to operate, not a script to run.

Rung 3: the managed path with SparkProxy

Rung 3 moves the browser pool, the exit IPs and the challenge runtime behind one HTTP call. SparkProxy's Scraping API lives at https://scrape.sparkproxy.io/api/v1 and authenticates with an X-API-Key header. New accounts get 1,000 free credits, so you can measure Temu before you commit to anything.

render_js defaults to true, which is what you want here: Temu's phantom scripts have to execute before anti-content exists. Add country_code to pin the storefront, because the storefront is not cosmetic. Temu's own window.__REGION_CONFIG__ maps each storefront to a numeric region id, and our unlisted exit was assigned region=37 with currency=CAD whether we asked for it or not. Setting the region cookie to 211 by hand did not change the served currency. Geography is decided by the exit IP.

StorefrontRegion idStorefrontRegion id
`us`211`de`76
`ca`37`fr`69
`uk`210`it`98
`au`12`nl`141
`nz`144`es`186
curl -s -X POST 'https://scrape.sparkproxy.io/api/v1' \
  -H 'X-API-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  --data '{
    "url": "https://www.temu.com/g-607575047260272.html",
    "render_js": true,
    "wait_for": "div[class*=\"goods-detail\"]",
    "wait": 8,
    "country_code": "US",
    "proxy_type": "premium",
    "stealth": true,
    "format": "html"
  }'

wait_for gives up after 30 seconds, and wait adds idle time after load so the oak XHR has landed before capture. stealth adds a homepage pre-warm and a forced Google referrer for +5 credits, which maps directly onto the cold-session behavior that worked best in our probes. proxy_type=premium selects the premium pool.

For production, take the envelope rather than raw HTML, because the envelope tells you what happened. json_response=true is the only parameter that returns it, and the body inside arrives base64-encoded. Decode before you match any marker, or every block check silently passes:

import base64, json, requests

API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json"}

def scrape_temu(goods_id, country="US"):
    body = {
        "url": f"https://www.temu.com/g-{goods_id}.html",
        "render_js": True,            # default; Temu needs it to mint anti-content
        "wait": 8,
        "country_code": country,      # +5 credits, pins storefront and currency
        "proxy_type": "premium",
        "stealth": True,              # +5 credits: homepage pre-warm + Google referrer
        "json_response": True,        # the ONLY way to get the envelope
    }
    env = requests.post(API, headers=HEADERS, json=body, timeout=180).json()

    html = ""
    if env.get("body"):
        raw = env["body"]
        html = base64.b64decode(raw).decode("utf-8", "replace") \
               if env.get("encoding", "base64") == "base64" else raw
    elif env.get("result_url"):
        html = requests.get(env["result_url"], headers=HEADERS, timeout=60).text

    return {
        "job_id":       env.get("job_id"),
        "status_code":  env.get("status_code"),
        "duration_ms":  env.get("duration_ms"),
        "credits_used": env.get("credits_used"),
        "html":         html,
    }

Two parameter rules that are easy to get wrong. format selects content conversion only (html, md, mdx, json, screenshot, pdf); format=json converts the page body, it does not give you the envelope. And extract_rules replaces the normal response with an extracted object, so never send it together with json_response:

# Structured extraction instead of the envelope. Returns {"extracted": {...}, "meta": {...}}
rules = {
    "title":   "h1",
    "price":   "div[class*='priceContainer'] span",
    "sold":    "div[class*='sold'] span",
    "gallery": {"selector": "div[class*='gallery'] img", "type": "src"},
}
r = requests.post(API, headers=HEADERS, timeout=180, json={
    "url": f"https://www.temu.com/g-{GOODS_ID}.html",
    "render_js": True, "wait": 8, "country_code": "US",
    "proxy_type": "premium", "extract_rules": rules,
})
print(r.json().get("extracted"))

For the rung 1 discovery sweep, batch mode is the cheap part of the bill. Pass comma-separated URLs; it works only with render_js=false, and the whole batch costs 1 credit:

rails = ["https://www.temu.com/",
         "https://www.temu.com/ca/channel/best-sellers.html",
         "https://www.temu.com/ca/channel/lightning-deals.html",
         "https://www.temu.com/ca/channel/new-in.html"]
r = requests.get(API, headers={"X-API-Key": "YOUR_API_KEY"}, timeout=180,
                 params={"url": ",".join(rails), "render_js": "false"})
d = r.json()
print(d["credits_used"], [(x["url"], x["httpStatus"], x["success"]) for x in d["results"]])

Credits, so you can model the run: 1 for a plain fetch, 5 with rendering, 10 for premium without JS, 25 for premium with JS, and 5 each for country_code, js_scenario and screenshot or PDF output. The rendered Temu call above lands at 15 credits (5 render + 5 country + 5 stealth). Credits are deducted up front and refunded automatically on failure.

If you plan to run your own browser pool instead and just want clean exits, the proxy side is plan-based rather than metered: entry pricing starts from $75/month with unlimited bandwidth, over 1M premium IPs across 80+ countries, random rotation and 5-minute auto-rotation, HTTP, HTTPS and SOCKS5 with IP-whitelist or user:pass auth, 1 Gbps ports, and a 24-hour free trial with no credit card. On a target like Temu, where a rendered product page is a megabyte of shell before you see a single price, per-GB billing punishes you for the target's own bloat. A plan does not.

Detect a silent block instead of trusting the status code

Given everything above, status-code checks are close to worthless on Temu. A 200 can be the real page, a 2.9 KB challenge, a login wall, or a stripped shell. Validate on content, and distinguish the states so your retry logic can respond differently to each:

import re

def temu_state(html, http_status=200):
    """Classify a Temu response. Each state needs a different reaction."""
    html = html or ""
    n = len(html)
    if http_status == 403:
        return "gateway-403"                       # back off this exit, hard
    if n < 6_000 and "assets/chl/js" in html:
        return "js-challenge"                      # needs a real JS runtime
    if "Temu | Login" in html or "Sign in / Register" in html:
        return "login-wall"                        # exit IP is burned, rotate
    if "window.rawData" not in html:
        return "ssr-stripped"                      # 200, no data: rotate, don't retry
    if not re.search(r'"goodsId"\s*:\s*"?\d+', html):
        return "rail-empty"                        # rawData present, products withheld
    return "ok"

ACTION = {
    "gateway-403": "rotate exit, cool down 10 min",
    "js-challenge": "escalate to render_js=true",
    "login-wall":  "retire exit from the pool",
    "ssr-stripped": "rotate exit immediately",
    "rail-empty":  "rotate exit, keep the session",
    "ok":          "parse",
}

Two details in there are load-bearing. The challenge test pairs a size bound with a string marker rather than trusting size alone, because a size floor on its own would have misled us badly: the 316 KB curl_cffi product shell is four times the size of the 79 KB one and contains exactly as much product data. And rawData gets checked before products, because "no rawData" and "empty rawData" come from different layers and want different retries. The general approach to block classification is expanded in how to detect when your scraper is blocked.

Honest limits

Four things we could not do, stated plainly rather than papered over.

You cannot mint anti-content outside a JS runtime. It is the output of the /api/phantom/xg/pfb/* pipeline executing in the page. There is no header you can hardcode and no signature you can replay for long. Any guide that shows you a Python call to oak/integration/render with a pasted token is showing you something that expires.

You cannot construct list_id. Deep pagination requires a live session that was handed one. A stateless worker pool with per-request IP rotation cannot walk a Temu result set at all, which is the opposite of how most price-monitoring crawlers are built.

Rendering does not rescue a burned IP. Our Chromium run generated a valid anti-content and still took a 403 and a login wall, because the exit was already flagged. Browser realism and IP reputation are independent variables, and Temu checks both.

The homepage rail prices only what Temu chose to show you. Ten items per cold hit from one promotional carousel, in whatever currency the exit resolved to. That is a genuine priced snapshot, and it still cannot answer "what does this specific SKU cost in Germany today", which is the question price monitoring actually asks.

One more thing worth being precise about: we never got a complete product payload during this session, because the IP was flagged before a clean exit was pinned to it. What we did verify is the mechanism, the endpoint, the body shape, the token requirement and every refusal code. The working path is a clean residential exit per session with rendering on, and that is exactly the part a managed endpoint exists to own.

Frequently asked questions

FAQ

No public one. It has an internal JSON endpoint, POST https://www.temu.com/api/oak/integration/render, which takes a goods_id and returns the product payload. Called without the browser-generated anti-content header it answered HTTP 403 with {"success":false,"error_code":40001,"error_msg":"NEED_LOGIN"} on 9 October 2026.

Because Temu strips the data instead of blocking you. Product pages at /g-.html never contain a price in the HTML, and the homepage stops serving its window.rawData product rail once your exit IP is flagged. Both responses are HTTP 200. Classify on content, not status.

Only on the homepage, and only from a cold IP. There window.rawData carried ten products under store.homeModuleList[n].data.goodsList[] with goodsId, goodsName, priceInfo.priceStr, salesTip and comment.goodsScore. Product pages, channel pages and the desktop search page served __DOC_SOURCE__ = "ssr" shells with no product object, no ld+json and no rel=canonical.

No, and it actively hurts. A Googlebot UA returned a 2,911-byte obfuscated JavaScript challenge on the product page, 2,907 bytes on search and 2,889 on the homepage, all HTTP 200, reproducible on repeat. The loader injects a script from static.kwcdn.com/upload-static/assets/chl/js/.

Pin the exit IP to that country. Temu assigns the storefront and currency from the exit, mapped through its own window.__REGION_CONFIG__ (us 211, ca 37, uk 210, de 76, fr 69). Our unlisted exit got region=37 and currency=CAD, and overriding the region cookie by hand did not change the served currency. With the Scraping API that is country_code, which adds 5 credits.

Split the work. Harvest goodsId values from the homepage and channel rails with render_js=false in batch mode, where the whole batch costs 1 credit, then spend rendered requests only on the IDs you actually need priced. A rendered, geo-pinned, stealth product fetch costs 15 credits, so the ratio between those two steps is your entire bill.

Special Discount ยท 20% off

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

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's residential and datacenter proxy network and its Scraping API. We spend our days on the same problems this guide describes: TLS and browser fingerprinting, challenge runtimes, session-scoped cursors, and the difference between a block and a quiet degradation. Every measurement above was reproduced from our own machines on the date stated, and every SparkProxy parameter is taken from the live documentation at sparkproxy.io/docs/scraping-api. When a target defeats us, we say so.

Keep reading

Related articles

How to Scrape Lowes Product Data by Store

How to scrape Lowes product data with store context intact: the sn cookie, Akamai denials that answer HTTP 200, and a Python ladder costed per credit.

SparkProxyยทGuides

How to Scrape Costco Product Data: What Works

How to scrape Costco product data: the product page ships price in JSON-LD, the listing grid ships nothing, and Kasada signs the API. Tested, with code.

SparkProxyยทGuides

How to Scrape SHEIN Product Data

How to scrape SHEIN product data: measured results against their risk gate, why TLS spoofing alone fails, and the JSON surface that answers unsigned.

SparkProxyยทGuides