🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

How to Scrape Target Product Data

Learn how to scrape Target data from the RedSky JSON API: price, TCIN, ratings, store-level availability, plus Akamai anti-bot handling and working code.

S SparkProxy 4 18 min read
Share
How to Scrape Target Product Data

Open a Target product page in your browser and view the source. The price is not there. Neither is the star rating, the per-store stock, or half the description. Target.com is a React application that renders almost nothing in its first HTML response and pulls the real product data from a JSON API called RedSky after the page loads. Try to scrape Target data by parsing the page HTML and you get an empty shell. This guide shows the approach that actually works: call the same RedSky endpoints the site calls, read clean JSON, and pull price, TCIN, ratings, and store-by-store availability without rendering a browser. It also covers where the anti-bot sits, why store-level pricing changes the numbers you collect, and how to stay on the right side of Target's terms and robots.txt.

What Target Product Data You Can Scrape

A single Target product page exposes more structured data than most retailers. Every field below comes back as JSON from the RedSky API, so you never have to parse HTML for it.

FieldRedSky JSON path (pdp_client_v1)ExampleNotes
Product title`data.product.item.product_description.title``Apple AirPods Pro (2nd Generation)`HTML-escaped string
TCIN`data.product.tcin``84770895`Target's internal SKU, stable per product
Brand`data.product.item.primary_brand.name``Apple`
Current price`data.product.price.current_retail``199.99`Numeric; varies by `pricing_store_id`
Formatted price`data.product.price.formatted_current_price``$199.99`Currency-formatted string
Regular price`data.product.price.reg_retail``249.99`Present when the item is on sale
Rating average`data.product.ratings_and_reviews.statistics.rating.average``4.6`0 to 5 scale
Review count`data.product.ratings_and_reviews.statistics.rating.count``12847`
Primary image`data.product.item.enrichment.images.primary_image_url``https://target.scene7.com/...`Scene7 CDN URL
Availability by store`data.product.fulfillment.store_options[]`see `pdp_fulfillment_v1`Store-scoped stock

RedSky renames and reshapes these paths every few months, so treat the table as a map, not a contract. Always inspect one live response before you wire a parser to it.


Why the Page HTML Is the Wrong Target

Target.com is a Next.js app. Its first HTML response ships a skeleton plus a large __NEXT_DATA__ script, and the browser then fires background XHR calls to redsky.target.com to fill in price, promotions, and live inventory. The most valuable, most volatile fields (current price and per-store stock) are the ones that arrive over those XHR calls, not in the initial HTML.

You have two ways to collect this data:

  1. Render the page with Playwright or Puppeteer, wait for hydration, and read the DOM. This works, but it is slow, memory-heavy, and easy to break every time Target ships a CSS change.
  2. Call RedSky directly. The site's own front end is doing this on every page load. You send the same request, get back the same JSON, and skip the browser entirely.

The second path is faster and cheaper by an order of magnitude. A rendered page can take three to six seconds and a full headless Chromium; a RedSky call returns in a few hundred milliseconds and costs a single request. For any serious volume of target scraping, the JSON API is the only sensible starting point. If you are weighing a hosted API against running your own browser fleet and proxy pool, this trade-off is the whole argument in web scraping API vs self-managed proxies.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The RedSky API That Powers Target.com

RedSky is Target's public product aggregation layer. The company has written about it openly on its engineering blog as a self-service platform for aggregated APIs. Every endpoint lives under one base path:

https://redsky.target.com/redsky_aggregations/v1/web/

The endpoints you need for product data are these:

EndpointPath suffixReturnsCore params
PDP core`pdp_client_v1`Title, price, brand, images, specs, rating summary`key`, `tcin`, `pricing_store_id`, `visitor_id`, `channel`
Fulfillment`pdp_fulfillment_v1`Ship / pickup / in-store stock by location`key`, `tcin`, `store_id`, `zip`, `state`, `latitude`, `longitude`
Search / PLP`plp_search_v2`Product grid for a keyword or category`key`, `keyword` or `category`, `count`, `offset`, `store_id`

Two parameters trip people up. key is a 40-character public web key that Target embeds in its own JavaScript bundle. It is not a secret you register for; it is the same key every browser uses, and it rotates every so often. visitor_id is any stable hex string; the API accepts a random one. channel is always WEB for the desktop site.

Because the key rotates, hardcoding it guarantees breakage. Pull it from the live page instead:

import re, json, requests

SCRAPE_ENDPOINT = "https://scrape.sparkproxy.io/api/v1"
SPARK_KEY = "sk-your-sparkproxy-key"   # from your SparkProxy dashboard

def scrape_raw(target_url: str, render_js: str = "false") -> str:
    """Fetch a URL through the SparkProxy Scraping API and return the raw body."""
    r = requests.get(
        SCRAPE_ENDPOINT,
        headers={"X-API-Key": SPARK_KEY},
        params={
            "url": target_url,
            "render_js": render_js,   # RedSky returns JSON, so no browser is needed
            "country_code": "US",     # Target serves US traffic only
        },
        timeout=45,
    )
    r.raise_for_status()
    return r.text

def get_web_key(sample_tcin: str = "84770895") -> str:
    """The public RedSky web key is embedded in Target's page bundle."""
    html = scrape_raw(f"https://www.target.com/p/-/A-{sample_tcin}")
    match = re.search(r'"apiKey":"([0-9a-f]{40})"', html)
    if not match:
        raise RuntimeError("Web key not found; the bundle layout changed.")
    return match.group(1)

TARGET_KEY = get_web_key()

Routing every request through the SparkProxy Scraping API gives you a clean US exit IP, automatic retries, and a single X-API-Key header instead of a proxy pool you have to babysit. Set render_js to false for RedSky JSON (one credit); reserve true for the rare case where you scrape the rendered page itself.


Find a Product's TCIN

Every RedSky product call is keyed on a TCIN (Target.com Item Number). You get it two ways.

From a product URL, it is the number after A-:

import re

def tcin_from_url(url: str) -> str | None:
    match = re.search(r"/A-(\d+)", url)
    return match.group(1) if match else None

tcin_from_url("https://www.target.com/p/apple-airpods-pro-2nd-gen/-/A-84770895")
# "84770895"

If you are starting from a keyword or a category rather than a known URL, pull TCINs from the search endpoint (covered in the search section). Each result in the grid carries its own tcin, which you then feed into the PDP and fulfillment calls.


Scrape Core Product Fields

Build the pdp_client_v1 URL, fetch it through the Scraping API, and read the JSON. One helper builds the URL, another parses the fields you care about.

from urllib.parse import urlencode

REDSKY = "https://redsky.target.com/redsky_aggregations/v1/web"

def scrape_json(target_url: str) -> dict:
    return json.loads(scrape_raw(target_url))

def pdp_url(tcin: str, pricing_store_id: str = "1859") -> str:
    params = {
        "key": TARGET_KEY,
        "tcin": tcin,
        "is_bot": "false",
        "pricing_store_id": pricing_store_id,
        "has_pricing_store_id": "true",
        "visitor_id": "0193A1B2C3D4E5F60718293A4B5C6D7E",
        "channel": "WEB",
        "page": f"/p/A-{tcin}",
    }
    return f"{REDSKY}/pdp_client_v1?{urlencode(params)}"

def parse_product(payload: dict) -> dict:
    p = payload["data"]["product"]
    item = p.get("item", {})
    price = p.get("price", {})
    stats = p.get("ratings_and_reviews", {}).get("statistics", {}).get("rating", {})
    return {
        "tcin": p.get("tcin"),
        "title": item.get("product_description", {}).get("title"),
        "brand": item.get("primary_brand", {}).get("name"),
        "price": price.get("current_retail"),
        "reg_price": price.get("reg_retail"),
        "on_sale": price.get("current_retail") != price.get("reg_retail"),
        "rating": stats.get("average"),
        "review_count": stats.get("count"),
    }

product = parse_product(scrape_json(pdp_url("84770895")))
print(product)
# {'tcin': '84770895', 'title': 'Apple AirPods Pro (2nd Generation)',
#  'brand': 'Apple', 'price': 199.99, 'reg_price': 249.99, 'on_sale': True,
#  'rating': 4.6, 'review_count': 12847}

Use .get() all the way down rather than direct key access. RedSky omits keys for items that lack promotions or reviews, and a KeyError mid-crawl will kill an otherwise clean run.


Get Store and ZIP-Level Pricing

Here is the detail most Target scraping tutorials miss, and the one that matters most if you monitor prices: the current price is store-scoped. Change pricing_store_id and the same TCIN can return a different current_retail, because clearance and markdown are localized to individual stores. Scrape only the default store and your dataset silently misses regional deals.

Availability works the same way and comes from a separate endpoint, pdp_fulfillment_v1, which takes a physical location:

def fulfillment_url(tcin: str, store_id: str, zip_code: str,
                    state: str, lat: float, lon: float) -> str:
    params = {
        "key": TARGET_KEY,
        "tcin": tcin,
        "is_bot": "false",
        "store_id": store_id,
        "store_positions_store_id": store_id,
        "has_store_positions_store_id": "true",
        "zip": zip_code,
        "state": state,
        "latitude": f"{lat:.3f}",
        "longitude": f"{lon:.3f}",
        "pricing_store_id": store_id,
        "has_pricing_store_id": "true",
        "channel": "WEB",
    }
    return f"{REDSKY}/pdp_fulfillment_v1?{urlencode(params)}"

def parse_stock(payload: dict) -> list[dict]:
    p = payload["data"]["product"]
    rows = []
    for opt in p.get("fulfillment", {}).get("store_options", []):
        pickup = opt.get("order_pickup", {})
        rows.append({
            "store": opt.get("location_name"),
            "store_id": opt.get("location_id"),
            "pickup_status": pickup.get("availability_status"),
            "on_hand": opt.get("location_available_to_promise_quantity"),
        })
    return rows

To compare a product across markets, loop over a small table of stores and collect both price and stock per location:

STORES = [
    # store_id, zip,   state, lat,     lon
    ("1859", "98801", "WA", 47.430, -120.320),
    ("3991", "10001", "NY", 40.750,  -73.997),
    ("2775", "78701", "TX", 30.267,  -97.743),
]

def price_across_stores(tcin: str) -> list[dict]:
    out = []
    for store_id, zip_code, state, lat, lon in STORES:
        prod = parse_product(scrape_json(pdp_url(tcin, pricing_store_id=store_id)))
        stock = parse_stock(scrape_json(
            fulfillment_url(tcin, store_id, zip_code, state, lat, lon)))
        out.append({"store_id": store_id, "zip": zip_code,
                    "price": prod["price"], "stock": stock})
    return out

That per-store spread is exactly what price-intelligence teams build on. If cross-retailer comparison is your goal, the same pattern extends to Walmart, Best Buy, and others, as covered in datacenter proxies for price comparison websites.


Scrape Ratings and Reviews

pdp_client_v1 already returns the rating average and total review count in its ratings_and_reviews.statistics block, which is enough for most dashboards. When you need the full review text, Target paginates reviews through their own aggregation call with count and offset:

def reviews_url(tcin: str, count: int = 50, offset: int = 0) -> str:
    params = {
        "key": TARGET_KEY,
        "tcin": tcin,
        "count": count,
        "offset": offset,
        "sort": "most_recent",
        "channel": "WEB",
    }
    return f"{REDSKY}/product_reviews_v1?{urlencode(params)}"

def all_reviews(tcin: str, page_size: int = 50, max_reviews: int = 500) -> list:
    reviews, offset = [], 0
    while offset < max_reviews:
        data = scrape_json(reviews_url(tcin, count=page_size, offset=offset))
        batch = data.get("data", {}).get("reviews", {}).get("results", [])
        if not batch:
            break
        reviews.extend(batch)
        offset += page_size
    return reviews

Stop as soon as a page returns fewer results than page_size or an empty list. That is the signal you have reached the end, and it avoids hammering the endpoint for pages that do not exist.


Scrape Search and Category Pages with Pagination

To collect TCINs at scale, work from plp_search_v2. It accepts either a keyword or a category node, plus count (results per page, default 24) and offset. Walk the offset forward until the grid runs dry.

def search_url(keyword: str, count: int = 24, offset: int = 0,
               store_id: str = "1859") -> str:
    params = {
        "key": TARGET_KEY,
        "keyword": keyword,
        "count": count,
        "offset": offset,
        "store_id": store_id,
        "pricing_store_id": store_id,
        "visitor_id": "0193A1B2C3D4E5F60718293A4B5C6D7E",
        "channel": "WEB",
        "page": f"/s/{keyword}",
    }
    return f"{REDSKY}/plp_search_v2?{urlencode(params)}"

def search_all(keyword: str, page_size: int = 24, max_items: int = 240) -> list[dict]:
    items, offset = [], 0
    while offset < max_items:
        data = scrape_json(search_url(keyword, count=page_size, offset=offset))
        products = data.get("data", {}).get("search", {}).get("products", [])
        if not products:
            break
        for prod in products:
            items.append({
                "tcin": prod.get("tcin"),
                "title": prod.get("item", {})
                             .get("product_description", {}).get("title"),
                "price": prod.get("price", {}).get("current_retail"),
            })
        offset += page_size
    return items

wireless_earbuds = search_all("wireless earbuds")

Target caps how deep search paging goes (typically a few hundred results per query), so narrow broad terms into tighter queries or category nodes rather than trying to page past the ceiling. Deeper coverage comes from more specific searches, not larger offsets.


Scrape Target Data Without Tripping Akamai

The main target.com domain sits behind Akamai Bot Manager, which drops sensor cookies (_abck, bm_sz) and scores browser behavior. The RedSky host is lighter, but it still rate-limits per IP and rejects requests with a stale or missing key. Here is what each defense looks like and how to get past it cleanly.

SignalWhat Target checksHow to pass
Akamai Bot ManagerSensor cookies and behavior on `target.com` page loadsRoute through the Scraping API or residential IPs; hit RedSky directly for data
Rate per IPRequests per minute from one addressRotate exit IPs; pace at 1 request / 1 to 3 seconds; back off on HTTP 429
Missing or stale keyRequests without a currently valid web keyRe-run `get_web_key()` when a call returns 401 or 403
`is_bot` flagA request that self-reports `is_bot=true`Send `is_bot=false`, exactly as the site does
Datacenter ASNCloud IP ranges flagged as non-residentialSet `premium_proxy=true` for the stubborn 403s

Two practical habits keep a Target crawler alive. First, refresh the web key on the fly instead of assuming it lasts forever:

def scrape_json_resilient(target_url: str) -> dict:
    global TARGET_KEY
    try:
        return scrape_json(target_url)
    except (requests.HTTPError, json.JSONDecodeError):
        TARGET_KEY = get_web_key()                     # key rotated; refresh once
        refreshed = re.sub(r"key=[0-9a-f]{40}", f"key={TARGET_KEY}", target_url)
        return scrape_json(refreshed)

Second, pace yourself. RedSky tolerates steady traffic far better than bursts. A short randomized delay between calls does more for your success rate than any single header trick. For the full anti-detection playbook, including TLS fingerprinting and header consistency, read how to avoid getting your proxy blocked.


Scrape Legally and Ethically

Scraping public product data is common practice, and courts have generally treated openly available web data differently from data behind a login. That is not a license to ignore the rules. Keep your collection defensible:

  • Read Target's robots.txt and Terms of Use first. Fetch https://www.target.com/robots.txt, honor its disallowed paths, and respect any Crawl-delay. Target's Terms restrict certain automated access, so understand what you are agreeing to.
  • Only touch public data. Product pages, prices, and public reviews are visible to any visitor. Never scrape anything behind a login, a cart, or a member account.
  • Collect no personal data. Reviewer nicknames and profiles are personal information. Aggregate ratings and counts, not identities.
  • Rate-limit like a good citizen. One request every one to three seconds per IP is plenty for price and stock monitoring. You are not trying to overload anyone's servers.
  • Use the data for legitimate purposes. Price monitoring, assortment research, and market analysis are standard. This is the same category of work described in how ecommerce companies use proxies for competitive intelligence.
import urllib.robotparser

rp = urllib.robotparser.RobotFileParser()
rp.set_url("https://www.target.com/robots.txt")
rp.read()

print(rp.can_fetch("*", "/p/-/A-84770895"))   # check before you crawl a path
print(rp.crawl_delay("*"))                      # respect any declared delay

None of this is legal advice. Rules and terms change, so review Target's current policies and your own jurisdiction before you run a crawler at scale.


Frequently asked questions

FAQ

Scraping publicly visible data such as prices, titles, and public reviews is generally lawful in the US, and courts have distinguished public data from data behind a login. You still have to respect Target's robots.txt and Terms of Use, avoid personal data, and rate-limit responsibly. This is general information, not legal advice, so check current terms and local law.

RedSky is Target's own product aggregation API at redsky.target.com, and it is what powers price, inventory, and reviews on every product page. You do not register for a key: the key parameter is a public 40-character web key embedded in Target's JavaScript bundle. It rotates periodically, so pull the current value from the live page rather than hardcoding it.

Because price is store-scoped. Clearance and markdown pricing is localized, so the same TCIN can return a different current_retail depending on the pricing_store_id you send. To scrape Target prices accurately across regions, loop over a set of store IDs and record the price for each rather than trusting a single default store.

The TCIN is the number after A- in a product URL, for example A-84770895. You can extract it with a short regex, or collect TCINs in bulk from the plp_search_v2 search endpoint, where every result in the grid carries its own tcin for you to feed into the product and fulfillment calls.

Yes, and you should. Target's product, price, and inventory data all come from RedSky JSON endpoints, so you can request that JSON directly and skip rendering entirely. A RedSky call returns in a few hundred milliseconds and one request, versus several seconds and a full Chromium instance for a rendered page.

Hit the RedSky JSON host rather than the Akamai-protected page, rotate US exit IPs, and pace requests at roughly one every one to three seconds with backoff on 429s. Refresh the web key when a call returns 401 or 403, and route through a residential or Scraping API endpoint for any IPs that draw a hard 403.


Limited-time · 50% off

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

Claim Discount

About the Author

SparkProxy Technical Team. The SparkProxy engineering team builds and maintains global datacenter and residential proxy infrastructure and a managed Scraping API for large-scale data collection. This guide reflects RedSky request patterns and anti-bot behavior observed on Target.com as of July 2026; endpoint paths and JSON shapes evolve, so validate against a live response before deploying. SparkProxy products referenced: the Scraping API (https://scrape.sparkproxy.io/api/v1), datacenter proxies, and residential proxies.

Citations: Target Tech: Empowering clients with self-service aggregated APIs · SparkProxy Scraping API documentation

Keep reading

Related articles