What goes wrong when this scraper fails
Anyone building price intelligence, a consumer price-comparison tool, or resale-market research needs to see the same public prices a fan sees: face-value availability on the primary marketplace (Ticketmaster) and resale asks on the secondaries (StubHub, SeatGeek). The problem is that no single public API spans all three, prices are dynamic and change fast in the final days before an event, and each site treats its listing pages as a heavily defended surface. You end up polling many events across many geographies on a tight cadence, which is exactly the traffic shape these sites are built to throttle.
Why this failure mode happens
Ticketmaster runs Queue-it / Akamai-style waiting rooms plus aggressive bot management (device fingerprinting, TLS/JA3 checks, PerimeterX-style challenges) because scalping bots have historically hammered its purchase path - so its defenses are tuned for hostility even against read-only traffic. StubHub and SeatGeek are lighter but still front their listing JSON with Cloudflare, rotate endpoint paths, and rate-limit per IP. On top of the anti-bot layer, prices are genuinely dynamic: primary inventory releases in waves, resale sellers reprice against each other, and everything is localized by country and currency - so a single fixed IP gives you a narrow, often stale window into a much larger, faster-moving market.
Challenges that make this hard to automate
- Ticketmaster's layered defense: Akamai/PerimeterX device fingerprinting plus Queue-it waiting rooms that intercept even read requests to hot events, returning challenge pages instead of price data.
- Geo-locked, currency-localized pricing: the same event URL returns different inventory, availability, and currency by exit-IP country, so undirected exits produce inconsistent, non-comparable rows.
- Endpoint churn on the secondaries: StubHub and SeatGeek rotate the internal JSON paths and query params their front-ends call, so a hardcoded endpoint quietly breaks and starts returning 404s or empty arrays.
- Freshness vs. volume: prices near an event move within minutes, but polling thousands of events fast enough to stay fresh is exactly the velocity pattern that trips per-IP rate limits.
- Silent soft-blocks: all three sites can return 200 OK with an empty or truncated listing set (or a stale cached page) instead of an outright 403, so bad data slips into your store looking valid.
Approaches that usually fail
- Single datacenter IP with a scraper library: works for a handful of low-traffic events, then gets rate-limited or CAPTCHA-walled within minutes on Ticketmaster and Cloudflare-fronted secondaries.
- Third-party 'ticket data' aggregator APIs: convenient but expensive, often stale, incomplete across all three marketplaces, and a black box you cannot verify against the live public page.
- Headless-browser farms without proxy geography control: burn huge CPU rendering pages and still get blocked because every browser exits from the same flagged IP range, and they cannot pin per-event country context.
- Manual spot-checks: a person opening event pages a few times a day - accurate but hopelessly unscalable and blind to the intra-hour price swings that matter most near an event.
When residential proxies fix this — and when they cannot
Rotating residential proxies give every request a fresh, real consumer IP, so consecutive polls of a hot event never stack on one address and trip velocity limits. Because you choose the exit country, you can pin each request to the geography that matters for that event - collecting the exact localized inventory, availability, and currency a real fan in that market would see, which is what makes prices comparable across marketplaces. Sticky sessions let a short multi-request burst (load event page, then fetch its listing JSON with the matching referer and tokens) stay on one exit so the site sees a coherent session, while per-request rotation remains the default for independent price checks.
How Aethyn residential proxies help here
Aethyn is a residential and mobile proxy network built for exactly this kind of high-cadence, geo-specific public-data collection. You address a single endpoint - proxy.aethyn.io - and encode rotation, country, city, and session behavior directly in the username, so the same code handles a per-request price sweep and a sticky page+JSON burst with one line changed. On the Elite tier you get the cleanest residential pools and per-request rotation on port 5499, which is what keeps you under Ticketmaster's reputation and velocity radar.
- Per-request rotation by default on port 5499 (Elite) so every Ticketmaster/StubHub/SeatGeek poll exits from a fresh residential IP - no manual IP pool management.
- Precise geo-pinning via -country-XX (and optional -city-name) so you collect the correct localized inventory, availability, and currency for each event's market.
- Sticky sessions (-session-TOKEN-lifetime-MINUTES, up to 30 min) to keep a page-load-then-fetch-JSON burst on one coherent exit when the referer/token flow requires it.
- Large residential and mobile pools that present as real consumer ISPs - the reputation signal Ticketmaster's Akamai/PerimeterX layer weighs most heavily.
- One stable endpoint and username-encoded config, so scaling from 10 events to 10,000 is a concurrency change, not an infrastructure change.
- Elite-tier exit quality tuned for the hardest anti-bot surfaces, reducing challenge/soft-block rates that otherwise poison your price data silently.
How to implement this with residential proxies
- 1
Establish a geo-pinned Elite proxy connection and verify your exit
Before touching any ticket site, confirm your proxy config resolves to the country you intend to collect from - a mismatched exit is the single most common cause of non-comparable price rows. Encode rotation and geography in the username: use the Elite port 5499, set the country, and leave the session token off so you rotate on every request. Hit an echo IP service first and assert the country matches, then move on.
pythonimport requests # Elite tier -> port 5499. Per-request rotation (no session token). PROXY = "http://aethyn-XXXXX-country-us:PASSWORD@proxy.aethyn.io:5499" proxies = {"http": PROXY, "https": PROXY} r = requests.get("https://ipinfo.io/json", proxies=proxies, timeout=20) info = r.json() print(info["ip"], info.get("country")) assert info.get("country") == "US", f"Wrong exit country: {info.get('country')}"Field note: Assert the exit country on every worker startup, not just once in dev. Residential pools are large and occasionally a request lands on a border-adjacent exit - failing loud here saves you from silently mixing US and CA inventory into the same price series.
- 2
Collect Ticketmaster public availability with a complete, realistic request
Ticketmaster is the most defended surface here - Akamai/PerimeterX fingerprinting plus Queue-it waiting rooms on hot events. Send a full, coherent header set (a bare User-Agent with no Accept-Language or Accept is a classic bot tell), rotate on every request, and detect the waiting-room/challenge response instead of parsing it as data. Read only public availability and price-range data exposed on the event's public discovery surface; do not touch the cart or purchase path.
pythonimport requests PROXY = "http://aethyn-XXXXX-country-us:PASSWORD@proxy.aethyn.io:5499" proxies = {"http": PROXY, "https": PROXY} headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://www.ticketmaster.com/", } # Public discovery/availability surface - read-only, no cart interaction. url = "https://www.ticketmaster.com/event/EVENT_ID" r = requests.get(url, headers=headers, proxies=proxies, timeout=30) body = r.text.lower() if r.status_code in (403, 429) or "queue-it" in body or "are you a robot" in body: raise RuntimeError(f"Blocked/queued (status={r.status_code}) - back off and re-rotate") print("OK", r.status_code, len(r.text))Field note: Treat any Queue-it redirect or challenge page as a hard signal to back off that event for several minutes, not to retry immediately. Hammering a waiting-room URL on a fresh IP each time just burns good residential exits against a door that is closed to everyone right now.
- 3
Pull StubHub resale listings from the public JSON feed
StubHub renders its listing grid from an internal JSON feed rather than static HTML, so parse the feed, not the DOM - it is far more stable and gives you per-listing price, quantity, and section. Load the public event page first, then request the listings JSON with the matching referer; a sticky session keeps both requests on one coherent exit. Localize by pinning the country so you get that market's currency and available inventory.
pythonimport requests # Sticky session so page-load + JSON fetch share one exit (lifetime in minutes). PROXY = "http://aethyn-XXXXX-country-us-session-sh42-lifetime-10:PASSWORD@proxy.aethyn.io:5499" proxies = {"http": PROXY, "https": PROXY} EVENT = "123456" s = requests.Session() s.proxies.update(proxies) s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", }) # 1) Warm the session on the public event page. s.get(f"https://www.stubhub.com/event/{EVENT}/", timeout=30) # 2) Fetch the public listings JSON with the event page as referer. listings = s.get( f"https://www.stubhub.com/api/events/{EVENT}/listings", headers={"Accept": "application/json", "Referer": f"https://www.stubhub.com/event/{EVENT}/"}, timeout=30, ) data = listings.json() for lst in data.get("listings", [])[:5]: print(lst.get("section"), lst.get("row"), lst.get("price", {}).get("amount"))Field note: Cache the discovered JSON endpoint path per marketplace and health-check it separately from your parser. StubHub and SeatGeek rotate these internal paths every so often; when the feed starts returning 404 or an empty listings array on events you know are live, that is your cue to re-derive the path from the page's network calls, not a real sell-out.
- 4
Pull SeatGeek grid prices and normalize across marketplaces
SeatGeek exposes a public listings/grid endpoint per event. Collect its price, fee-inclusive total, and deal-score fields, then normalize all three marketplaces into one schema so a StubHub 'price' and a SeatGeek 'display price' actually mean the same thing. Fees and currency handling differ per site - capture them explicitly rather than assuming the headline number is comparable.
pythonimport requests PROXY = "http://aethyn-XXXXX-country-us:PASSWORD@proxy.aethyn.io:5499" proxies = {"http": PROXY, "https": PROXY} headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0 Safari/537.36", "Accept": "application/json", "Referer": "https://seatgeek.com/", } EVENT = "7654321" r = requests.get(f"https://seatgeek.com/api/event/{EVENT}/listings", headers=headers, proxies=proxies, timeout=30) raw = r.json() def normalize(source, section, price, fees, currency): return {"source": source, "section": section, "base_price": price, "est_total": price + (fees or 0), "currency": currency} rows = [normalize("seatgeek", l.get("section"), l.get("price"), l.get("fee_amount"), l.get("currency", "USD")) for l in raw.get("listings", [])] print(len(rows), "normalized SeatGeek rows")Field note: Store base price, estimated fees, and fee-inclusive total as separate columns - never just one 'price'. Ticketmaster face value, StubHub all-in resale, and SeatGeek deal-score pricing are only meaningfully comparable once fees and currency are explicit; collapsing them early is how a comparison view ends up misleading.
- 5
Validate freshness and shape before writing anything
A 200 OK proves the request completed, not that the data is good. Guard every row with structural and freshness checks: non-empty listing set, prices within a sane band, currency matching the pinned country, and a collected-at timestamp. Reject soft-blocks and stale cached pages here so they never reach your store. This gate is where price-intelligence pipelines earn their trust.
pythonfrom datetime import datetime, timezone def validate(rows, expected_currency="USD"): if not rows: raise ValueError("Empty listing set - likely a soft block, not a sold-out event") prices = [r["base_price"] for r in rows if r.get("base_price")] if not prices: raise ValueError("No parseable prices - parser or endpoint drift") if min(prices) <= 0 or max(prices) > 100000: raise ValueError(f"Prices out of sane band: {min(prices)}..{max(prices)}") bad_ccy = [r for r in rows if r.get("currency") != expected_currency] if bad_ccy: raise ValueError(f"Currency mismatch - wrong exit geo? {bad_ccy[0]['currency']}") for r in rows: r["collected_at"] = datetime.now(timezone.utc).isoformat() return rows # clean = validate(rows, expected_currency="USD") # db.write(clean)Field note: Alert on a sudden drop in row count per event, not just on exceptions. A marketplace quietly returning 8 listings where it returned 400 yesterday usually means endpoint drift or a partial soft-block - catching that delta early is the difference between a data gap you notice and one you find three weeks later in a wrong dashboard.
- 6
Schedule, throttle, and back off to stay fresh without hammering
Near an event, prices move within minutes; far out, hourly is plenty. Tier your polling cadence by time-to-event so you spend velocity where it matters, and add jittered exponential backoff on any block signal. Keep per-event concurrency modest and let per-request rotation spread load across the residential pool rather than pushing one IP hard.
pythonimport time, random def cadence_seconds(days_to_event): if days_to_event <= 1: return 300 # 5 min in the final day if days_to_event <= 7: return 1800 # 30 min in the final week return 3600 # hourly otherwise def fetch_with_backoff(fetch_fn, max_tries=4): delay = 5 for attempt in range(max_tries): try: return fetch_fn() # each call rotates to a fresh Elite exit except RuntimeError as e: # block/queue signal from earlier steps sleep = delay + random.uniform(0, delay) # jitter print(f"Backing off {sleep:.1f}s ({e})") time.sleep(sleep) delay *= 2 raise RuntimeError("Giving up after repeated blocks - cool this event down")Field note: Jitter is not optional. A perfectly periodic poll every 300.0 seconds is itself a fingerprint; adding random slack makes your traffic look like organic checking and keeps you off velocity-based flags even when each individual request looks clean.
Best practices that keep scrapers reliable
- Collect only public, non-personal data - listing prices, availability, section/row, and currency. Never scrape buyer or seller personal information, and always operate within each site's Terms of Service; respecting ToS and jurisdictional law is a hard requirement, not a nice-to-have.
- Stay strictly read-only. This is price intelligence: never automate carting, checkout, queue/waiting-room bypass, or purchase-limit evasion - automated purchasing is illegal in many jurisdictions (US BOTS Act) and out of scope entirely.
- Pin geography deliberately with -country-XX so every price row is tied to a known market and currency; never let an undirected exit decide which localized inventory you record.
- Prefer the public JSON feeds over rendered HTML on StubHub and SeatGeek - they are more stable, cheaper to fetch, and give you structured price/fee fields - but health-check the endpoint paths since they rotate.
- Rotate per request by default and reserve sticky sessions for genuine multi-request bursts (page-load-then-JSON); holding a sticky exit longer than needed just concentrates load on one IP.
- Validate freshness and shape before writing - assert non-empty listings, sane price bands, and matching currency so soft-blocks and stale caches never contaminate your dataset.
- Tier polling cadence by time-to-event and add jittered backoff; spend request velocity where prices actually move and back off hard on any challenge signal.
Common mistakes that burn proxy budget
- Treating a 200 OK as valid data - storing empty or truncated listing arrays as if the event genuinely sold out, silently corrupting your price history.
- Retrying a Ticketmaster Queue-it/challenge page immediately on a fresh IP instead of backing off - the door is closed for everyone, and you just burn clean residential exits against it.
- Ignoring exit geography so US, UK, and CA inventory get mixed into one price series with mismatched currencies that look numerically comparable but are not.
- Scraping rendered HTML on StubHub/SeatGeek instead of their JSON feeds, then breaking constantly on layout changes that never touched the underlying data.
- Collapsing base price, fees, and all-in total into a single 'price' column, making primary face value and secondary resale asks falsely comparable.
- Polling on a fixed, un-jittered interval - a perfectly periodic request cadence is itself a bot fingerprint that trips velocity-based detection even when each request looks human.