Craigslist Search Listings
Purpose
Return a list of Craigslist postings matching a query in a given city and category — title, price, location, posting time, lat/lon, posting ID, and canonical listing URL. Read-only; never posts, edits, replies to, or flags any listing.
When to Use
- Daily / hourly monitoring of new listings matching a query (cars, bikes, apartments, jobs, free stuff, etc.).
- Bulk extraction across multiple cities, categories, or price/bedroom ranges.
- Anywhere you'd otherwise scrape Craigslist HTML — the JSON API is faster, cheaper, and structurally more reliable than rendering
/search/{cat}and harvesting per-listing anchors.
Workflow
The Craigslist web UI is a thin client over a public JSON API at https://sapi.craigslist.org — no auth, no cookies, no session state, no anti-bot stealth required. Send a Referer header matching the target city subdomain; if your outbound IP is in a different region than the target city, add postal=<zip>&search_distance=<mi> to the query — the API geo-scopes by IP only when no postal is supplied (see the gotcha below). A residential proxy is not required. Lead with the API path; the browser path works as a fallback but pays a ~100× cost premium because the search page is fully JS-rendered (browse snapshot returns 0 a11y refs and harvesting per-listing URLs costs ~3 turns each).
-
Pick city + category (and optionally subarea). City is the Craigslist subdomain (
sfbay,newyork,losangeles,seattle,chicago,boston, …). Category is the search-path abbreviation (sssfor-sale-all,ctacars+trucks,apaapartments,gggfor-sale-by-owner,jjjjobs,zipfree stuff, etc.). To scope to a specific subarea (city-within-region), prefix the category insearchPath— e.g.searchPath=sfc/apafor SF-proper apartments,searchPath=eby/ctafor East Bay cars. Subarea codes are listed in each response'sdata.decode.locations[i][2]. Subarea-scoping is significantly more efficient than fetching region-wide and filtering client-side (e.g.apareturns ~9,800 bay-wide vs. ~253 forsfc/apa). -
First page:
GET https://sapi.craigslist.org/web/v8/postings/search/full ?searchPath={cat} &query={q} &sort={date|rel|priceasc|pricedsc} &batch=1-0-360-1-0 &lang=en&cc=us Referer: https://{city}.craigslist.org/Returns JSON with
data.totalResultCount,data.items[], and decode tables underdata.decode. Confirm the response is scoped to the right region viadata.areas(e.g.{"3": {"name": "newyork"}}) — if it shows the wrong city, addpostal=<zip>&search_distance=<mi>(any ZIP in the target metro) to override the IP-based geo-scope.Common filter params (append as query args; check
data.humanReadableParamsto confirm acceptance):min_price,max_price,min_bedrooms,max_bedrooms,min_bathrooms,bundleDuplicates=1,hasPic=1,postal=<zip>,search_distance=<mi>,availabilityMode=available,auto_make_model=<text>,min_auto_year/max_auto_year,min_auto_miles/max_auto_miles. Unrecognized params are silently dropped. -
Decode each item.
data.items[]is an array of positional arrays. Critical: many fields are offsets / lookup keys, not absolute values — always read againstdata.decode.*:item[0]—postingIdOffset. Absolute id =data.decode.minPostingId + item[0].item[1]—postedDateOffset(seconds). Absolute epoch =data.decode.minPostedDate + item[1].item[2]—categoryId(integer). Maps to a 3-letter sub-category abbreviation (cat3) used in canonical URLs. The mapping is not in the response — it's a fixed Craigslist enum. Observed values:68 → bik(bicycles),93 → spo(sporting goods),122 → pts(parts),197 → bop(bicycle parts/accessories),5 → fua(furniture by-owner),101 → foa(furniture all). Other categories will need to be back-derived or resolved via the redirect-URL fallback in step 4.item[3]— price as integer (0 or missing for free items).item[4]—"locIdx:hoodDescIdx:hoodIdx~lat~lon". Look updata.decode.locations[locIdx]→[1, city, subareaAbbr];data.decode.locationDescriptions[hoodDescIdx]→ display location string; parselat~lonfor coordinates.- Title — last array element that is a plain string (i.e. not a tagged
[code, ...]block). Forcta(cars+trucks) this isitem[-1]. Forapa(apartments) and other housing categories, a trailing[5, beds, sqft]housing-meta block pushes the title earlier — iterate from the end and take the first plain string. - Tagged blocks
[code, value]mid-array:code === 5is[beds, sqft](housing categories);code === 6is the URL slug;code === 10is the formatted price string ("$1,350");code === 4is image-id refs;code === 13is the geo/cluster cell.
-
Construct canonical post URL:
https://{city}.craigslist.org/{subareaAbbr}/{cat3}/d/{slug}/{postingId}.htmlpostingIdfrom step 3 (offset + minPostingId)subareaAbbrfromdata.decode.locations[locIdx][2](e.g.nby,sby,sfc,eby,pen)cat3from the categoryId enum (step 3)slugfrom the[6, ...]tagged block
Wrong
cat3will 404. If you don't know the mapping for a categoryId, fall back tohttps://{city}.craigslist.org/search/{cat}?postingId={postingId}which redirects to the canonical URL. -
Paginate (only if results > 360):
GET https://sapi.craigslist.org/web/v8/postings/search/batch ?batch=1-{OFFSET}-1080-1-0-{startTs}-{endTs} &cacheId={cacheId from step 2} Referer: https://{city}.craigslist.org/Increment
OFFSETin steps of 1080.startTs/endTsare thedata.cacheTsfrom step 2's response and the current epoch.
Browser fallback
When the API is unreachable or geo-locked away from the target city (rare — postal=<zip> almost always resolves it), open https://{city}.craigslist.org/search/{cat}?query={q}&sort=date directly (bypassing the bare-domain geo-redirect), then capture browse get html body and split per-listing chunks by the regex <div data-pid="(\d+)" class="cl-search-result. Within each chunk, extract:
- URL:
<a class="main" href="(...\.html)"(gallery view) orclass="...posting-title" href="(...)"(text view) - Title:
class="label">([^<]+)</span>inside the posting-title anchor - Posted:
class="result-posted-date">([^<]+)</span>(relative time, e.g. "6h ago" or "4/30") - Neighborhood:
class="result-location">([^<]+)</span> - Price:
class="priceinfo">([^<]+)</span> - Housing meta (br/sqft, when present):
class="housing">([^<]+)</span>
Skip browse snapshot/click on /search/ — snapshot returns 0 refs and click-through costs ~3 turns per listing. Stable across cta and apa in prior validation.
Site-Specific Gotchas
- Geo-redirect on bare domain:
https://www.craigslist.org/redirects to a city based on the request IP. Always open{city}.craigslist.orgdirectly. Confirmed 2026-05-19: bare-domain still redirects; deep-link to subdomain is the only reliable bypass. - API geolocates by request IP —
postal=<zip>&search_distance=<mi>overrides it: No auth, no cookies, no anti-bot — but if nopostalis supplied, the API scopes results to the city corresponding to the request's source IP, not theRefererheader (e.g. a NY query from an SF IP silently returns{"1": {"name": "sfbay"}}results). Addingpostal=<zip>for any ZIP in the target metro plussearch_distance=<mi>forces the result set to that region. Re-verified 2026-05-19 with directbrowse cloud fetchcalls returning correct NYC apartments (postal=10001&search_distance=10, 860 results,data.areasshowsnewyork/newjersey/longisland/hudsonvalleycluster) and SF Bay bicycles (postal=94103&search_distance=25, 5,635 results,data.areas: {"1": "sfbay"}). A residential proxy is not required and is actively counterproductive —browse cloud fetch --proxieswithoutpostalis also geo-locked to the proxy's exit-IP region, and addingpostalto a direct fetch is ~8× faster than the proxy path. Always verify scope viadata.areasin the response. - Snapshot returns 0 refs on
/search/: The search page is fully JS-rendered (React). Don't usebrowse snapshot/clickto enumerate listings — fall back tobrowse get html body+ regex per the Browser fallback section. - Compact response format:
data.items[]uses positional arrays +data.decode.*lookup tables to keep the response small (~130 KB for 360 items). Don't expect named fields per item — decode by position. - Pagination batch sizes: First page is ~360 (
batch=1-0-360-1-0); subsequent batches are 1080 each (batch=1-OFFSET-1080-1-0). Mixing these sizes will cause the response to silently truncate. - Free items have no price:
item[3]may be0or absent. Map both toprice: null(or"free") in your output; don't render$0. - Posting time precision: The rendered HTML shows relative ("< 1 hr ago", "6h ago", "4/30"); absolute epoch is only available via the API as
data.decode.minPostedDate + item[1]. item[0]is NOT the postingId — it's an offset fromdata.decode.minPostingId. Naïvely treatingitem[0]as the postingId produces 404s on every URL you construct.data.decode.locationsindexing is per-response, not stable. The same query at two different times can producelocations[1] → ["sfbay","sfc"]vs.locations[1] → ["sfbay","eby"]. The decode block is rebuilt per cache TTL — always look uplocations[locIdx]from the response in hand, never cache or hardcode the table across requests.- Neighborhood labels are unreliable:
data.decode.locationDescriptionsvaries per response and per category. The same neighborhood may appear under different label-table indices across responses, may be missing in some categories (e.g. "Russian Hill" shows up inapabut is absent fromctadecode tables), and is sometimes replaced by a generic city-level label by the poster. For neighborhood-scoped searches, use lat/lon bounding-box matching onitem[4]'s coordinates as a fallback or supplement to label-string matching. Example bbox for North Beach + Russian Hill:lat 37.794–37.810, lon -122.425 to -122.404. - Categories are an undocumented enum — the response decode tables don't include the
categoryId → cat3mapping; observed values across iters:5→fua, 68→bik, 93→spo, 101→foa, 122→pts, 197→bop(and likely many more for non-bicycle queries). The redirect URLhttps://{city}.craigslist.org/search/{cat}?postingId={id}is the safest fallback when an unknown categoryId is encountered. - Rate-limit self-imposed: No formal block but Craigslist throttles aggressive clients with terse 403s. Keep ≤ 1 req/s sustained; pagination loops should sleep ~1s between batches.
- Don't waste time on stealth fingerprinting — the API has no anti-bot today (verified 2026-05-19 via direct unproxied
browse cloud fetchreturning 200 + 134 KB JSON on the first try with no Referer). The expensive Browserbase--verified --proxiesflags do not improve success rate and actively slow the path.
Expected Output
{
"city": "sfbay",
"category": "sss",
"query": "bicycle",
"sort": "date",
"total_results": 5635,
"listings": [
{
"posting_id": 7927446618,
"title": "Kryptonite Evolution 1090 3 Ft Long 10mm Steel Bike Chain BRAND NEW",
"price": "$100",
"location": "san leandro",
"subarea": "eby",
"category_id": 197,
"cat3": "bop",
"lat": 37.6875,
"lon": -122.1445,
"posted_at_epoch_seconds": 1779140987,
"url": "https://sfbay.craigslist.org/eby/bop/d/san-leandro-kryptonite-evolution-ft/7927446618.html"
}
]
}
Free items omit price:
{
"posting_id": 7926112233,
"title": "Free moving boxes — Mission",
"price": null,
"location": "mission district",
"subarea": "sfc",
"cat3": "zip",
"url": "https://sfbay.craigslist.org/sfc/zip/d/.../7926112233.html"
}
When the postal-override resolves to a multi-area cluster (NY metro returns 4 sub-areas), data.areas enumerates them and individual listings carry the correct sub-area in locations[locIdx][1]:
{
"city": "newyork",
"category": "apa",
"query": "studio",
"total_results": 860,
"areas": ["newyork", "newjersey", "longisland", "hudsonvalley", "elmira"],
"listings": [
{
"posting_id": 7935281805,
"title": "Newly renovated Charming Spacious Studio Near Prospect Park",
"price": "$2,599",
"location": "brooklyn",
"subarea": "brk",
"cat3": "apa",
"lat": 40.6724,
"lon": -73.9573,
"url": "https://newyork.craigslist.org/brk/apa/d/brooklyn-newly-renovated-charming/7935281805.html"
}
]
}