Facebook Marketplace Scraper for extracting listing titles, descriptions, photos and item IDs from Facebook.com. This repo has a free Facebook Marketplace web scraping script you can run right now, and a Facebook Marketplace listing data API that turns a listing URL into structured JSON.
Last updated: 2026-07-20. Working against Facebook Marketplace as of July 2026, and re-verified whenever Meta changes their markup.
This repo reads public listings only, and collects no personal data. The three listings sampled here are public classified adverts for a used vehicle or a machine part, captured on 2026-07-20. The API's seller field came back null on all 18 calls made to them, so no seller identity enters the data at all. Nothing in the committed samples, the scripts or the rendered images contains a person's name, photograph, address or contact details: a candidate listing whose text carried a home address was dropped rather than redacted, and no listing photo is rendered into any image on this page. Facebook Pages, posts, groups and events have their own reference in the Facebook Scraper repo.
Every JSON block on this page was captured from the live API on 2026-07-20. Long CDN image URLs are truncated where marked and each block says exactly what was cut; every field shown is verbatim. Full uncut samples are committed in facebook_marketplace_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from facebook_marketplace_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python facebook_marketplace_scraper_api_codes/listing.pyThose three lines return this, live from Facebook Marketplace:
{
"id": "4948179818741919",
"title": "1987 944S Porsche parts car",
"description": "Rebuilt engine with crank scraper, with14,000 miles.lsd rear-end, sport leather seat, Fuchs, needs a new front wind shield, a gas tank repair. The previous owner ran ethanol and it gummed up in the...",
"images": [
"https://scontent-dfw5-1.xx.fbcdn.net/v/t39.84726-6/736198361_898669625914112_4411632949091742647..."
],
"data_source": "opengraph"
}...that is 5 of the 13 fields on a listing, picked for the intro; the full 13 are below. Point it at a set of listing URLs and you get a row each:
That is the whole point of this repo. The rest of this page is how it works, starting with the free Facebook Marketplace scraper: one HTTP request, no key, real listing data.
- Free Facebook Marketplace Scraper
- Avoid getting blocked when scraping Facebook Marketplace
- Facebook Marketplace Scraper API reference
- Archive listings and catch the ones taken down
- Measured latency
- License
Facebook server-renders a public Marketplace listing's headline fields into its OpenGraph <meta> tags, so you can extract listing data without a headless browser, JavaScript rendering, or a login. No key, no cost:
python free_scraper/facebook_marketplace_free_scraper.py https://www.facebook.com/marketplace/item/4948179818741919/Source: free_scraper/facebook_marketplace_free_scraper.py. It reads og:title, og:description, og:image and og:url, and emits id, url, title, description, image. It parses first and only reports "no data" when the OpenGraph block is genuinely absent, so a listing that renders is never miscounted as a block.
After running the command, your terminal should look something like this:
That is a real run. For a listing URL you already have, the free path works. The next section covers the two things that decide whether a request gets through, and the thing this surface would not do for us at all.
Facebook Marketplace has a reputation as a login-walled surface, and for browsing that reputation is exactly right. For a listing you already have the URL for, it is wrong. We ran the script above from an ordinary home internet connection on 2026-07-20 and it returned the listing on 15 of 15 requests across the three listings sampled. What changes the answer is your client string:
Lying about your client is the one thing that reliably fails. Same URL, same machine, same minute, one header different. A full Mozilla/5.0 ... Chrome/126.0.0.0 Safari/537.36 User-Agent from a plain HTTP client was refused with a 400 and a 1,542-byte error page titled Error, on 3 of 3 attempts. An empty User-Agent got a 146,468-byte "Update Your Browser" document with zero og: tags, on 3 of 3. curl/8.7.1, python-requests/2.34.2 and this repo's own self-describing facebook-marketplace-scraper/1.0 each got the real ~548 KB listing document with 7 og: tags and the listing title, on 3 of 3. That is why the script here sends a truthful UA, and why you should leave it alone.
That table is the entire output of a script in this repo, so you can reproduce it yourself:
python free_scraper/ua_test.pyThe harder problem is not the listing. It is finding one.
Marketplace browse and search returned no listings at all. Two category pages and one search page, tested on 2026-07-20, each answered HTTP 200 with half a megabyte of document containing zero /marketplace/item/ links. Search did not even keep its own title: /marketplace/nyc/search/?query=forklift came back titled Facebook. An item URL you already hold returns the listing. On the surfaces we could test, then, enumeration is not available: you bring the URLs, from your own inventory, a partner feed, or wherever your listings come from, and this repo turns each one into JSON.
python free_scraper/browse_test.pyHere is what genuinely bites you, none of which a proxy fixes:
| What bites you | Why | What it costs you |
|---|---|---|
| Listings do not stay up | A Marketplace listing is removed when it sells. The document stops being served, and the endpoint answers 502 instead of 200. |
Anything you did not store is gone. Snapshot on first sight; the watch script exists for exactly this. |
A 502 can also be transient |
Live listings answered 200 on 14 of 15 calls in one run, and the exception resolved on the next attempt. |
Confirm before you conclude a listing has gone, or your archive will record takedowns that never happened. |
| You cannot enumerate listings | Category and search pages returned 0 item links on 3 of 3 surfaces tested, at HTTP 200 and 526,679 to 529,865 bytes each. | Any plan that starts "crawl Marketplace for..." does not survive contact. Source your URLs elsewhere. |
| A browser UA from a non-browser client is refused | 400, 1,542 bytes, <title>Error</title>, on 3 of 3. |
A single header turns a working scraper into a silent failure. |
| An empty UA gets a decoy document | HTTP 200, 146,468 bytes, <title>Update Your Browser</title>, 0 og: tags, on 3 of 3. |
Your parser sees nothing and you blame your parser. |
| HTTP 200 is not proof of data | Every case above returns 200. The status code tells you Facebook answered, not that it answered with a listing. | Check the payload. Both scripts here do. |
| The markup moves | The og block shape changes a few times a year. Your regex silently returns None. |
Ongoing maintenance, plus alerting smart enough to tell "empty" from "broken". |
The managed option. The Chocodata Facebook Marketplace Scraper API turns a listing URL into parsed JSON instead of ~548 KB of HTML, with a ~99% success rate, a pinned egress location so two runs are comparable, and the client-string handling above already solved. Free for the first 1,000 requests.
The API earns its money when the parser stops being something you want to own: when you need the response already parsed and shaped, the null-handling below already mapped, and someone else on the hook the next time Meta moves the markup.
Below is the Facebook Marketplace Scraper API reference to get you started: one endpoint, its parameters, a runnable call, and the response it returns.
curl "https://api.chocodata.com/api/v1/facebook/marketplace?api_key=YOUR_KEY&url=https://www.facebook.com/marketplace/item/4948179818741919/"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/facebook/marketplace",
params={"api_key": "YOUR_KEY",
"url": "https://www.facebook.com/marketplace/item/4948179818741919/"},
timeout=90,
)
r.raise_for_status() # a 502 means no public document came back
item = r.json()
print(item["title"], "|", len(item["images"]), "photo(s)")
# 1987 944S Porsche parts car | 1 photo(s)After running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
country |
string (ISO-2) | no | us |
Egress location. Must be exactly 2 characters: usa returns a 400. Facebook localises what it renders to the location it sees, so pin this for reproducible results. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
Real captured error bodies, not paraphrases. Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
A required param is missing or the wrong type. Body lists the exact issue under issues. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up ($0.90 / 1,000 requests, never expires) or upgrade. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
extraction_failed |
No public listing document came back for this URL. | no | Retry. A sold or removed listing looks exactly like this. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string. Both are shown below, and both are committed in errors.json.
There is no 404. An item id that resolves to nothing returns 502, on 4 of 4 attempts, and so does a listing that has been taken down. See the listing endpoint.
The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/facebook/marketplace?api_key=totally_invalid_key_123&url=https://www.facebook.com/marketplace/item/4948179818741919/"{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}A missing required param, verbatim. Note it names the exact requirement rather than a generic 400:
{
"error": "invalid_params",
"issues": [
{
"code": "custom",
"message": "url or id is required",
"path": []
}
]
}There is no per-minute request cap. The limit is concurrency: how many requests you may have in flight at once.
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed it and you get 429, not a queue. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. A request can take up to ~5s (see Measured latency), which is why the examples use timeout=90.
Sizing: at Pro (50 concurrent) and a ~3.1s median call, one worker pool sustains roughly 50 / 3.1 = 16 requests/second. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(url):
r = requests.get("https://api.chocodata.com/api/v1/facebook/marketplace",
params={"api_key": KEY, "url": url}, timeout=90)
return url, (r.json() if r.ok else None)
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for url, item in pool.map(one, listing_urls):
print(url, item["title"] if item else "no public document")One public Marketplace listing, by item URL or item id.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string (URL) | one of url / id |
- | Full listing URL, facebook.com/marketplace/item/<id>/. |
id |
string | one of url / id |
- | The numeric item id on its own. |
country |
string (ISO-2) | no | us |
Egress location. |
curl "https://api.chocodata.com/api/v1/facebook/marketplace?api_key=YOUR_KEY&url=https://www.facebook.com/marketplace/item/4948179818741919/"Real response. Complete object, all 13 fields verbatim; the two CDN image URLs are truncated where marked and description carries Facebook's own trailing ... (full sample):
{
"id": "4948179818741919",
"url": "https://www.facebook.com/marketplace/item/4948179818741919/",
"title": "1987 944S Porsche parts car",
"price": null,
"currency": "USD",
"description": "Rebuilt engine with crank scraper, with14,000 miles.lsd rear-end, sport leather seat, Fuchs, needs a new front wind shield, a gas tank repair. The previous owner ran ethanol and it gummed up in the...",
"location": null,
"condition": null,
"seller": null,
"images": [
"https://scontent-dfw5-1.xx.fbcdn.net/v/t39.84726-6/736198361_898669625914112_4411632949091742647..."
],
"thumbnail": "https://scontent-dfw5-1.xx.fbcdn.net/v/t39.84726-6/736198361_898669625914112_4411632949091742647...",
"source": "facebook",
"data_source": "opengraph"
}description is the field most people come for: it is the seller's own listing copy, which on a vehicle or a machine part is where the specification actually lives. title is the second, and it is whatever the seller typed. Some vehicle listings arrive with a middle dot separating the trim, so "2008 Ford Ranger · Xlt" splits cleanly on · (second sample); others, including the Porsche above, are free text with no separator at all. Parse defensively.
Read the field behaviour, it is load-bearing:
descriptionis capped at 200 characters with a trailing...that Facebook adds itself. Both long listings sampled came back at exactly 200; a short one came through complete at 69 characters. The cap is the source's, not ours.price,location,conditionandsellercame backnullon all 18 calls across the three listings in this repo on 2026-07-20. Sellers frequently type the number into the listing body instead, solisting.pyships aprice_from_description()that pulls it back out: on the motorcycle sample it recovers600from"Selling as is \n600$OBO". It is a regex over free text, so treat it as a hint and not a price feed.currencyisUSDon every listing sampled, and it is a fallback rather than a reading. Withpricenull there is no source value behind it. Do not convert against it.imagesheld exactly 1 photo on all 18 calls, andthumbnailis that same URL. The URLs carry Facebook's ownohsignature andoeexpiry parameters, so they stop working: fetch the bytes if you need to keep the image.data_sourcewasopengraphon all 18 calls. It tells you the og block resolved rather than degrading to a<title>fallback, which is a useful gate for detecting a markup change.
There is no 404. An item id that resolves to nothing returns 502 extraction_failed, on 4 of 4 attempts, and retrying does not change it. That one status covers every reason a listing might have no public document, and it does not distinguish between them: of the nine item URLs collected while building this repo, four resolved and five came back 502. Live listings returned 200 on 18 of 18 calls in that session and on 14 of 15 in a later one, so a lone 502 is retryable rather than proof a listing has gone: listing_watch.py confirms one with a second call before it records a takedown.
Runnable: facebook_marketplace_scraper_api_codes/listing.py
A Marketplace listing is a disappearing document, so the most useful thing you can do with this endpoint is run it on a schedule against the URLs you care about. listing_watch.py snapshots every listing it can still reach into a local SQLite dataset, reports title and description edits, and flags the ones that have gone:
python facebook_marketplace_scraper_api_codes/listing_watch.pyPass your own item URLs as arguments; with none, it watches the three listings sampled in this repo.
Those are two real runs, half an hour apart. The first has nothing to compare against, so it seeds the database and says so; the second diffs against the stored rows and reports them unchanged. A 200 -> 502 transition surfaces as TAKEN DOWN since the last run, confirmed with a second call first, and an edited body surfaces as TITLE OR DESCRIPTION EDITED. Export the archive with one command:
sqlite3 -header -csv facebook_listings.db "select * from snapshots" > listings.csvOne API call per listing per run. Your one-time 1,000 free requests covers roughly a month of daily checks on 30 listings.
Because a 502 does not say why a listing has no public document, the script records the transition rather than naming a cause. What it gives you is the date the document stopped resolving, and the last copy of it that did.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20. This includes the upstream fetch, the anti-bot handling, and the parse:
| Endpoint | Median | Range | 2xx | n |
|---|---|---|---|---|
/facebook/marketplace |
3.1s | 1.3 to 5.1s | 8/8 | 8 |
Read the range, not just the median: the same listing URL, called eight times in a row, took 1.3s on its fastest call and 5.1s on its slowest, a 4x spread with no change on our side. Size your timeouts off the top of the range, not the middle.
That is one laptop on one afternoon, and n=8 is too small a sample to say anything about the ~99% platform benchmark either way. Reproduce it with the scripts in this repo.
MIT. See LICENSE.








