How to Scrape Facebook Marketplace Listings and Prices
Scrape Facebook Marketplace public listings, prices, and locations the right way: past the login wall, out of the GraphQL JSON, with residential proxies.

To scrape Facebook Marketplace you have to beat the one defense that stops most people on request one: the login wall. Marketplace is a login-first surface bolted onto the public web, its listing data hides inside GraphQL JSON rather than the HTML you can see, and Meta runs one of the harshest anti-bot stacks online. This guide stays in the narrow, defensible lane of public data only. It opens with the law, because Meta lost a public-data scraping case in 2024 and that shapes what is safe to collect, then shows exactly where price and location fields live, which URL parameters target a city, and why a residential IP plus a real rendered browser is the price of entry.
Ethics and law come first
Facebook Marketplace is not a neutral product catalog. Behind most listings sits a real, identifiable person selling a couch or a car, so the ethics carry more weight than on a pure e-commerce target, and they decide the design before you write any code.
Start with the case that changed the calculus. In Meta Platforms v. Bright Data (N.D. Cal., January 2024), a federal judge granted summary judgment to the scraper on Meta's contract claims, and the reasoning matters: Meta's terms bind logged-in members, and Bright Data collected public data while logged out, so the court found no breach of those terms for that activity. That ruling is the strongest signal you will find that logged-out public collection sits on defensible ground, and it also draws the bright line for this guide. The moment you log in, you are a member bound by the terms, and the analysis flips.
Three legal questions sit underneath any Marketplace project, and "it's public" only answers the first one:
- Unauthorized access. In the US, the Ninth Circuit's ruling 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 is about access, not a licence to do whatever you like with what you collect.
- Contract. Meta's terms prohibit automated collection. Bright Data found those terms apply to logged-in members, so staying logged out is not a nicety here, it is the whole legal footing. Never authenticate.
- Data protection. A listing ties to a seller, and a seller is a person. Under GDPR and similar laws, public personal data is still personal data, and public availability is not a lawful basis on its own.
Guardrails that keep a Marketplace project defensible:
- Collect public, logged-out data only. No sessions, no cookies from a real account, no bypassing the login modal.
- Login-walled content is out of scope. Seller profiles, message threads, "reply" contact details, and anything the site hides behind the gate stay off limits. If a page only renders after login, treat it as unreachable.
- Take listing facts (title, price, coarse location, image, category), not dossiers on the people posting them.
- Rate-limit yourself and back off on errors so you never degrade the service for real buyers and sellers.
- Honour deletion. If a listing disappears, drop it from your store.
- Get counsel involved before anything commercial. This is engineering guidance, not legal advice.
The legitimate reasons to want this data are real: price research on used goods, local supply and demand analysis, resale arbitrage, and competitive pricing. If your use case is broader social listening, the business-side patterns live in Using Proxies for Social Media Monitoring. For the sibling Meta property with its own privacy weight, see How to Scrape Instagram Public Data. The point of this section is that the use case has to survive scrutiny before the pipeline is worth building.
The login wall is the whole game
Every Marketplace scraper lives or dies on one behaviour: Facebook increasingly gates the browse experience behind a login or "continue" modal, and the gate frequently returns HTTP 200 with a page that carries no listings. If your code trusts the status code, response.ok is True, you save the page, and you have stored a login prompt instead of results. You have to read the body.
The gate is not uniform, and that inconsistency is the single most useful thing to understand before you build. Two surfaces behave very differently for a logged-out client:
- Individual item pages at
/marketplace/item/are the URLs people paste into chats and forums, and Facebook serves them to logged-out visitors far more often, complete with Open Graph tags and embedded JSON. These are your most reliable public source./ - Search and category browse grids at
/marketplace/are gated more aggressively. Sometimes they render for a clean residential IP with a real browser, sometimes they bounce you to/search /login/?next=. Treat a rendered grid as a bonus, not a guarantee.
So the practical strategy is not "hammer the search page until it gives up". It is: pull what the search grid gives you when it renders, and treat individual item URLs as the dependable layer underneath. Detect the wall explicitly on every response, because a 200 that contains a login form is a block wearing a disguise:
def is_login_wall(html: str) -> bool:
"""Marketplace's login gate returns HTTP 200, so the status code lies."""
markers = (
"login_form",
'"loginbutton"',
"you must log in to continue",
"/login/?next=",
"log in to see more",
)
low = html.lower()
return any(m.lower() in low for m in markers)
If a specific URL returns the wall persistently even through a clean residential IP with rendering, that content is login-gated, and login-gated content is out of scope. Do not reach for a logged-in session to get past it. That is the exact line the Bright Data ruling draws, and crossing it trades your legal footing for a few extra rows.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
What public Marketplace data you can collect
A logged-out item page exposes a stable set of fields through Open Graph meta tags and an embedded JSON blob. The markup drifts and the JSON keys get obfuscated between releases, but the fields you can reach are consistent. Here is the reference set worth pulling as of mid-2026, with an honest note on which are reachable without login.
| Field | Where it lives (logged-out) | Logged-out reachable? |
|---|---|---|
| Listing title | `og:title`, `marketplace_listing_title` in JSON | Yes, on item pages |
| Price | `product:price:amount` meta, `listing_price` in JSON | Yes, on item pages |
| Location (city, region) | `og:title`/`og:description`, `location.reverse_geocode` | Often, and only coarse (city level) |
| Primary image | `og:image`, `primary_listing_photo` | Yes |
| Listing URL / ID | `/marketplace/item/ | Yes |
| Category | JSON `marketplace_listing_category_id` | Usually |
| Posted / creation time | JSON `creation_time` | Sometimes |
| Description | `og:description`, `redacted_description` | Partial, often truncated |
| Seller name / profile | Behind login | No, out of scope |
| Seller contact / messages | Behind login | No, out of scope |
| Exact GPS coordinates | Not exposed publicly | No, approximate city only |
Two field notes save you time. Marketplace deliberately shows an approximate location, usually a city or neighbourhood, never a precise pin, so do not build a workflow that expects street-level coordinates. And the price comes back as a formatted string in some places ($450) and a raw amount plus currency in others (450, USD), so normalize to an integer of minor units downstream and keep the currency code beside it.
Where the data lives: GraphQL and embedded JSON
Here is the part most tutorials get wrong. Facebook Marketplace is a Relay app, so the listings you see are fetched through GraphQL POST requests to https://www.facebook.com/api/graphql/, each carrying a doc_id and a variables blob. Open your browser network tab on a Marketplace search and you will watch those XHR responses stream in as JSON. It is tempting to replay them directly.
Resist that for logged-out scraping. Those GraphQL calls depend on rotating doc_id values and per-session tokens (fb_dtsg, lsd) that Facebook mints on page load and cycles constantly, so a replayed request breaks within days and often needs a session you should not have. The durable path for public data is different: render the URL and read the JSON the page bootstraps itself with. Facebook ships the first screen of Relay data inside tags in the initial HTML, and that embedded JSON is the same shape the GraphQL endpoint returns, without the token dance.
The keys are obfuscated and nested deeply, and they change, so do not hard-code a path. Walk every JSON blob on the page and match on the field names you need, the way a resilient parser handles any Relay app:
import json
from selectolax.parser import HTMLParser
def iter_json_blobs(html: str):
tree = HTMLParser(html)
for node in tree.css('script[type="application/json"]'):
raw = node.text()
if not raw:
continue
try:
yield json.loads(raw)
except json.JSONDecodeError:
continue
def walk(obj):
"""Yield every dict nested anywhere in the parsed JSON tree."""
if isinstance(obj, dict):
yield obj
for value in obj.values():
yield from walk(value)
elif isinstance(obj, list):
for value in obj:
yield from walk(value)
def find_listings(html: str) -> list[dict]:
seen = {}
for blob in iter_json_blobs(html):
for node in walk(blob):
# a marketplace listing object carries a title and a price object
if "marketplace_listing_title" in node and "listing_price" in node:
price = node.get("listing_price") or {}
geo = (node.get("location") or {}).get("reverse_geocode") or {}
listing_id = node.get("id")
seen[listing_id] = {
"id": listing_id,
"title": node.get("marketplace_listing_title"),
"price": price.get("formatted_amount_zeros_stripped") or price.get("amount"),
"currency": price.get("currency"),
"city": geo.get("city"),
"region": geo.get("state"),
"url": f"https://www.facebook.com/marketplace/item/{listing_id}/" if listing_id else None,
}
return list(seen.values())
The match keys (marketplace_listing_title, listing_price, location.reverse_geocode) reflect what Marketplace shipped in mid-2026. Verify them against a live capture before a big run, because Facebook renames and re-nests these often. This read-the-bootstrapped-JSON pattern is how you handle any client-rendered site, and How to Scrape Dynamic JavaScript Websites covers the general technique of pulling data from a site's own JSON instead of its rendered DOM.
City and location URL parameters
Marketplace geography is set by the URL path, not by your IP. The city segment decides which inventory you see, so a request routed through a US exit still returns London listings if the path says London. That is the lever you use to target a market.
| Path or parameter | Purpose | Example |
|---|---|---|
| `/marketplace/ | City browse root | `/marketplace/nyc/` |
| `/marketplace/ | Search within a city | `/marketplace/la/search?query=bike` |
| `/marketplace/ | Category within a city | `/marketplace/seattle/category/vehicles` |
| `query` | Search keywords | `?query=road+bike` |
| `minPrice` / `maxPrice` | Price band | `?minPrice=100&maxPrice=500` |
| `daysSinceListed` | Recency filter (1, 7, 30) | `?daysSinceListed=7` |
| `sortBy` | Result order | `?sortBy=creation_time_descend` |
| `exact` | Exact keyword match | `?exact=false` |
The segment is either a readable city slug (nyc, seattle, london) or a numeric Marketplace location ID. Not every city has a memorable slug, so the reliable way to get an identifier is to open Marketplace for that city in a normal browser and copy the path segment out of the address bar. Store that mapping once and reuse it. Even though geography rides on the path, set your exit country to match the market anyway, because a US city served through a German exit is exactly the kind of mismatch that trips a checkpoint.
A small helper keeps the URL building tidy so you can point the same code at any city, category, and filter set:
from urllib.parse import urlencode
def marketplace_search_url(location: str, query: str, **filters) -> str:
base = f"https://www.facebook.com/marketplace/{location}/search"
params = {"query": query, **filters}
return f"{base}?{urlencode(params)}"
# Mountain bikes under $500 listed in the last week, in Los Angeles
url = marketplace_search_url(
"la", "mountain bike", maxPrice=500, daysSinceListed=7,
)
Why Marketplace is hard to scrape
Marketplace breaks naive scrapers faster than almost any target on this list. Four defenses do the damage, and they stack.
Datacenter IPs are dead on arrival. Facebook flags datacenter address ranges immediately and answers with a login redirect or a checkpoint. A static datacenter proxy does not survive a single useful request here.
Fingerprinting. Facebook reads TLS fingerprints (JA3/JA4), header order, and a pile of JavaScript signals. A raw Python client looks nothing like Chrome, so you get walled regardless of the User-Agent string you send. Real content needs a genuine browser or a fingerprint-matching client.
The login gate. As covered above, the gate returns HTTP 200 with an empty-of-listings page, so status codes lie and you must inspect the body.
Rate limits. Push too fast from one IP and Facebook escalates from soft checkpoints to a hard block on the address. The thresholds are unpublished and they move.
| Signal | What you'll see | How to handle it |
|---|---|---|
| Datacenter block | Instant login redirect or checkpoint from a datacenter IP | Route through residential exits (`premium_proxy`) |
| Fingerprint block | Empty shell or challenge from a raw HTTP client | Render with a real browser (`render_js`), add `stealth` |
| Login wall | HTTP 200, login form in body, `/login/?next=` | Detect in the body, rotate IP, retry; if persistent, it's login-gated and out of scope |
| Rate limit | Repeated checkpoints or 429 on one address | Fresh residential IP per request, low concurrency, back off with jitter |
The through-line is that Marketplace needs two things at once: residential IP reputation and a real rendered browser. Miss either and you get the wall. A managed scraping API folds both into request parameters, which is why the setup below is short. For the deeper theory on staying unblocked, How to Avoid Getting Your Proxy Blocked and What Is IP Blacklisting and How to Avoid It cover it end to end.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL and handles the residential routing, rotation, browser rendering, and anti-detection layer for you. You send one request and get the rendered HTML back. For Marketplace, four parameters carry the weight:
render_js=true: Marketplace hydrates from GraphQL, so a raw fetch returns a shell.premium_proxy=true: routes through residential IPs, the only kind that survives Facebook's defenses.stealth=true: adds anti-detection layers on top of rendering (it requiresrender_js).country_code: the ISO alpha-2 exit country, matched to the city you are targeting.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. Because Marketplace paints results after load, add wait_for with a selector that only appears once listings render, so the API captures the page after the data arrives:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.facebook.com/marketplace/la/search?query=mountain+bike" \
--data-urlencode "render_js=true" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "stealth=true" \
--data-urlencode "country_code=us" \
--data-urlencode "wait_for=a[href*='/marketplace/item/']"
The full parameter list and response fields live in the Scraping API docs. A rendered residential request with stealth is the expensive tier, so if you are weighing this against building your own browser farm and residential pool, Web Scraping API vs Self-Managed Proxies lays out the build-versus-buy math honestly.
Scrape Marketplace search results
Wrap the request so every call carries the Marketplace-specific parameters, and give it a generous timeout since a rendered request drives a real browser through a residential exit.
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch(url: str, country: str = "us", wait_for: str | None = None) -> str:
params = {
"url": url,
"render_js": "true", # Marketplace hydrates from GraphQL, not static HTML
"premium_proxy": "true", # residential exits; datacenter IPs get a checkpoint
"stealth": "true", # extra anti-detection layers, needs render_js
"country_code": country,
}
if wait_for:
params["wait_for"] = wait_for
resp = requests.get(API, headers={"X-API-Key": API_KEY}, params=params, timeout=120)
resp.raise_for_status()
return resp.text
Now put the pieces together. Build the city search URL, fetch it, guard against the login wall, then extract listings from the embedded JSON:
html = fetch(
marketplace_search_url("la", "mountain bike", maxPrice=500),
wait_for="a[href*='/marketplace/item/']",
)
if is_login_wall(html):
print("Login-gated for this request, rotate and retry or fall back to item URLs")
else:
for row in find_listings(html):
print(row["title"], "|", row["price"], row["currency"], "|", row["city"])
When the search grid renders, find_listings hands you a de-duplicated set of rows with an ID and item URL for each. When it does not, the login-wall guard catches it, and you fall back to fetching the individual item URLs you already know about, which is the more dependable layer. Collect the id values you discover into your own store so a search that renders once seeds item-page fetches that keep working even when the grid gates.
Parse a listing's price and location
Item pages are the reliable source, and they give you two ways in. The simplest is Open Graph, which Facebook exposes on logged-out item pages for link previews. When the product tags are present, this is a three-selector job:
def parse_item_og(html: str) -> dict:
tree = HTMLParser(html)
def meta(value: str, key: str = "property") -> str | None:
node = tree.css_first(f'meta[{key}="{value}"]')
return node.attributes.get("content") if node else None
return {
"title": meta("og:title"),
"description": meta("og:description"),
"image": meta("og:image"),
"price": meta("product:price:amount"),
"currency": meta("product:price:currency"),
"url": meta("og:url"),
}
Open Graph is convenient but thin, and Facebook does not always emit the product:price tags. So treat it as the fast path and fall back to the embedded JSON when a field is missing. The same find_listings walk works on an item page, because the page bootstraps that single listing's object into its JSON just like the grid does:
def parse_item(html: str) -> dict:
og = parse_item_og(html)
if og.get("price"):
return og
# Open Graph price missing, fall back to the embedded JSON blob
listings = find_listings(html)
return listings[0] if listings else og
item_html = fetch(
"https://www.facebook.com/marketplace/item/1234567890123456/",
wait_for="meta[property='og:title']",
)
record = parse_item(item_html)
print(record["title"], record.get("price"), record.get("currency"), record.get("city"))
Two clean-up steps make the output usable. The og:image URL is a signed, expiring CDN link, so if you keep the image, rehost it immediately rather than storing the raw URL. And normalize price into an integer plus a currency code before storing, because a dataset that mixes "$1,200", "1200", and "1.2K" is useless for the median-price analysis that is usually the whole point.
Rate limits and staying unblocked
Facebook tracks pressure per IP and per fingerprint, and it punishes bursts harder than most targets. Four habits keep a Marketplace scraper healthy at volume:
- Rotate the exit IP per request. With the Scraping API this is automatic;
premium_proxy=truehands you a fresh residential IP each call. - Keep concurrency low. Two to six workers is plenty. A wide burst from correlated IPs is the fastest way to a checkpoint.
- Back off with jitter. On a wall, retry with exponential backoff plus a random delay so a batch of failures does not retry in lockstep.
- Cache hard. A used-bike price does not change minute to minute. Do not re-scrape an item you pulled an hour ago; read from your store and only refresh on a schedule.
import time, random
def fetch_with_retry(url: str, attempts: int = 3, **kw) -> str | None:
for i in range(attempts):
html = fetch(url, **kw)
if not is_login_wall(html) and "marketplace" in html.lower():
return html
time.sleep(2 ** i + random.random()) # exponential backoff + jitter
return None
Persist as you go rather than holding a run in memory, so a crash at listing 8,000 does not cost you the first 7,999. Stamp each row with a scraped_at timestamp so you can build clean price time series and prove exactly when a data point was collected, which also strengthens the compliance record. If the same URL walls you three times through clean residential exits, stop retrying it: that content is gated, and no amount of rotation makes login-walled data public.
Frequently asked questions
FAQ
It depends on jurisdiction and what you collect. In the US, scraping public, logged-out pages generally does not violate the CFAA under hiQ v. LinkedIn (9th Cir. 2022), and in Meta v. Bright Data (N.D. Cal., 2024) a court found that Meta's terms bind logged-in members, so collecting public data while logged out did not breach them. That footing disappears the moment you log in, and any seller personal data still falls under GDPR and similar laws. Stay logged out, take listing facts not seller profiles, and get legal advice before any commercial use.
Yes, within limits. Individual item pages at /marketplace/item/ are served to logged-out visitors with Open Graph tags and embedded JSON, so title, price, image, and coarse location are reachable. Search and category browse grids are gated more aggressively and sometimes bounce a logged-out client to a login page. Never log in to get past the wall, because logging in makes you a member bound by Meta's terms and puts the content out of scope.
Marketplace is a Relay app that fetches listings through GraphQL POST requests to facebook.com/api/graphql/, but it also embeds the first screen of that data as JSON inside tags in the initial HTML. For logged-out scraping, parse that embedded JSON rather than replaying GraphQL, because the endpoint depends on rotating doc_id values and per-session tokens that break fast. The keys are obfuscated and nested, so walk the JSON tree and match on field names like marketplace_listing_title and listing_price instead of hard-coding a path.
Marketplace geography rides on the URL path, not your IP, so /marketplace/ decides which city's inventory you see. The segment is a city slug (nyc, seattle) or a numeric Marketplace location ID that you copy from the address bar for that city. Filter with query parameters like minPrice, maxPrice, and daysSinceListed, and set your exit country_code to match the market so the geography and the IP do not contradict each other.
Facebook flags datacenter IPs on sight, reads TLS and browser fingerprints, and serves a login gate that returns HTTP 200 so status-code checks miss it. To pull public data reliably you need residential IPs, a real rendered browser, low concurrency, and backoff. With the SparkProxy Scraping API that is premium_proxy=true, render_js=true, and stealth=true, plus a wait_for selector so the page is captured after listings render. If a URL still walls you, that content is login-gated and out of scope.
Not for reading other people's listings. Meta's Commerce and Catalog APIs let a business manage its own catalog and, for eligible partners, list items to Marketplace, but there is no sanctioned endpoint that returns arbitrary public listings on demand. That gap is why people scrape the logged-out public pages, and it is also why staying inside public, non-personal data and off the login wall matters so much.
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 Stack Overflow Data (Questions, Answers)
Learn how to scrape Stack Overflow data the right way: the official Stack Exchange API, filters, backoff, the CC BY-SA data dump, and proxy-safe code.
How to Scrape Redfin Data: Listings, Prices, Market
Learn how to scrape Redfin data: pull listings, prices, and property details from Redfin's Stingray JSON API and CSV export, and handle its rate limits.

How to Scrape IMDb Data: Ratings, Cast, Reviews
Learn how to scrape IMDb data: titles, ratings, cast, and reviews. Pull IMDb's JSON-LD and hidden JSON, then use the official datasets for bulk facts.
