OfferUp Marketplace Item Search
Purpose
Search the OfferUp marketplace for a query term and return the current for-sale listings — each listing's title, price, location, condition, item-detail URL, thumbnail image, and delivery flags (local pickup / shipping). Read-only; never contacts a seller, makes an offer, or posts. Results are geo-scoped to the request's location (OfferUp derives location from the request IP — see gotchas).
When to Use
- "What's for sale on OfferUp matching
<query>near me?" - Price-comparison / deal-monitoring across a query with a min/max price band.
- Bulk extraction of marketplace listings for a search term, optionally filtered by category, condition, price, and sort order.
- Any flow that needs the listing grid (title/price/location/URL) without needing to open each item detail page.
Workflow
OfferUp's search page (/search?q=...) is a server-rendered Next.js page: the full listing feed is embedded in the page's <script id="__NEXT_DATA__"> JSON blob, produced by an internal modularFeed GraphQL query that runs server-side during SSR. This means the fastest, cheapest, most reliable path is a single proxied HTTP GET of the search URL followed by parsing that JSON — no scripted browser, no clicking, no waiting for client-side hydration. The site is behind Cloudflare (WAF), but the Browserbase Fetch API with --proxies sails through and returns the real SSR HTML (HTTP 200), not a challenge page.
1. Fetch the search page over a residential proxy
browse cloud fetch "https://offerup.com/search?q=<url-encoded query>" --proxies
The response is a JSON envelope: { id, statusCode, headers, contentType, encoding, content }. The rendered HTML is in .content. --proxies is important: a bare (non-proxy) fetch also returns 200 with SSR data, but it is geo-located to a datacenter (e.g. "Boardman, OR") and returns a degraded feed (~9 listings). The proxied fetch returns the full feed (~44–50 tiles) from a residential location.
2. Extract and parse __NEXT_DATA__
Pull the JSON out of .content:
<script id="__NEXT_DATA__" type="application/json">{ ... }</script>
The listings live at:
props.pageProps.searchFeedResponse.looseTiles[]
Each entry is a "tile" with a __typename. Filter to __typename === "ModularFeedTileListing" — the looseTiles array is a mixed feed that also contains ModularFeedTileGoogleDisplayAd, ModularFeedTileSellerAd, and (on empty searches) ModularFeedTileSearchAlert. Only listing tiles carry a real item.
For each listing tile, read tile.listing:
| Field | Source |
|---|---|
listing_id | listing.listingId |
title | listing.title |
price | listing.price (string, no currency symbol; may be "115" or "200.00"; null/absent for free/contact-for-price) |
location | listing.locationName (e.g. "Providence, RI") |
condition | listing.conditionText (often null) |
image_url | listing.image.url |
flags | listing.flags (e.g. ["LOCAL_PICKUP"], ["SHIPPING"]) |
vehicle_miles | listing.vehicleMiles (vehicles only, else null) |
is_firm_price | listing.isFirmPrice |
3. Build the canonical item URL
https://offerup.com/item/detail/<listingId>
4. Read the resolved location and result count
- Derived location:
props.pageProps.searchFeedResponse.feedOptions[0].labelShort(e.g."Westwood"), or the page<title>("Search: <q> near <City, ST> - OfferUp"). - Result count: number of
ModularFeedTileListingtiles you extracted.
5. Optional filters — append URL query params (all verified working)
| Param | Values | Notes |
|---|---|---|
q | url-encoded query | required |
price_min | integer | inclusive lower bound |
price_max | integer | inclusive upper bound |
sort | best_match (default), -posted (newest), distance, price (low→high), -price (high→low) | |
cid | category id: 1=Electronics & Media, 2=Home & Garden, 3=Clothing/Shoes/Accessories, 4=Baby & Kids, 5=Vehicles, 6=Toys/Games/Hobbies | |
radius | 5,10,20,30,50 (miles) | distance from the resolved location |
Example (iPhones $200–$400, cheapest first): https://offerup.com/search?q=iphone&price_min=200&price_max=400&sort=price
Browser fallback
If the Fetch API is unavailable or you specifically need client-side behavior, drive a stealth remote session instead. This is ~100× more expensive and unnecessary for read-only extraction, but it works:
sid=$(browse cloud sessions create --keep-alive --verified --proxies \
| node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).id))")
export BROWSE_SESSION="$sid"
browse open "https://offerup.com/search?q=iphone" --remote
browse wait load
browse wait timeout 3000 # feed hydrates 1–3s after load
browse get markdown body # listings render under "## Current listings"
browse cloud sessions update "$sid" --status REQUEST_RELEASE
Both --verified and --proxies are required for the interactive browser path (bare sessions can hit the Cloudflare WAF challenge). Extract the same __NEXT_DATA__ JSON from browse get html body, or read the rendered markdown links […TITLE$PRICELOCATION](/item/detail/<uuid>).
Site-Specific Gotchas
- Location is IP-based and NOT controllable via URL params. OfferUp derives the search location from the request's source IP (the residential proxy exit).
lat=/lon=query params are silently ignored — verified: a request with LA coordinates still returned Davis/Sacramento CA results because that was the proxy exit. There is nozip=/postal=override on the web search URL. Proxy exits rotate per request, so the resolved city can differ between two identical fetches (observed: Westwood MA → Davis CA → Encino CA across runs). To target a specific metro you would need a proxy pinned to that region, or the interactive UI location picker (which persists location in session state, not reproducible via a stateless fetch). Always read back and report the resolved location (feedOptions[0].labelShortor the page title). looseTilesis a mixed feed — always filter by__typename. ~44 of 50 tiles areModularFeedTileListing; the rest areModularFeedTileGoogleDisplayAd(1) andModularFeedTileSellerAd(~5, sponsored). Sponsored/ad tiles do not carry a normallistingobject. Treating every tile as a listing yields ad garbage.- No-results is a distinct, non-error shape. A query with no matches returns HTTP 200 with
looseTilescontaining a singleModularFeedTileSearchAlerttile and zeroModularFeedTileListingtiles. This issuccess: true, result_count: 0, not a failure. priceis a bare string with no currency symbol —"115","200.00". Free / contact-for-price items may havenullor an absent price. Don't assume it parses as a clean integer.conditionTextis usuallynulleven though the site exposes a Condition filter. Condition is a search filter (NEW,OPEN_BOX,REFURBISHED,USED,BROKEN,OTHERviaCONDITION) more than a per-tile field.- Pagination is NOT via a
page/offset URL param.?page=2returns an empty feed (0 listing tiles). The feed uses asearchFeedResponse.nextPageCursor(a long gzip+base64 token) consumed by themodularFeedGraphQL query on infinite scroll. The stateless SSR fetch only yields the first page (~44–50 listings). For deeper results you must replay themodularFeedGraphQL operation (POST /api/graphql) with the cursor plus the client headers (x-ou-d-token,x-ou-session-id,x-ou-usercontext) — not attempted here; first-page depth is sufficient for typical item-search use. - The search feed GraphQL op runs server-side, not in the browser. Watching the browser network tab you'll only see auxiliary ops (
GetConfigs,GetUserLocationWithId,GetPrivacyStates,GetFederatedLoginInfo) hitPOST /api/graphql— the actualmodularFeedsearch query is executed during SSR and its result is baked into__NEXT_DATA__/initialApolloState. Don't waste time looking for a client-side search XHR to intercept; parse__NEXT_DATA__instead. - Cloudflare WAF is present but not blocking the SSR document. The homepage probe reports
cloudflare+cloudflare-waf, and the HTML embeds thecdn-cgi/challenge-platformscript, but the/searchdocument itself returns 200 with real data over the Browserbase Fetch API (--proxies) and over a--verified --proxiesbrowser session. No captcha was encountered in testing. A bare browser session (no verified/proxies) is the risky path — prefer stealth if you must use the browser. statics.offerup.combuild hash in asset URLs changes on deploys (2026.38.3.0-…) — don't hardcode it; you never need it for search extraction.
Expected Output
{
"success": true,
"query": "iphone",
"resolved_location": "Providence, RI",
"sort": "best_match",
"result_count": 44,
"listings": [
{
"listing_id": "ea75335d-31b2-3e45-93e9-ebc52607b67e",
"title": "iPhone 11",
"price": "115",
"location": "Providence, RI",
"condition": null,
"flags": ["LOCAL_PICKUP"],
"image_url": "https://images.offerup.com/48Q1gt4ZOPO3GYqqCGEanQ_9fWQ=/250x444/65a0/65a0d70b0484414f9d9a16acd76af4c9.jpg",
"url": "https://offerup.com/item/detail/ea75335d-31b2-3e45-93e9-ebc52607b67e"
}
],
"error_reasoning": null
}
No results (valid, non-error):
{
"success": true,
"query": "zzqxwlkjhgfdsa123notreal",
"resolved_location": "Encino, CA",
"result_count": 0,
"listings": [],
"error_reasoning": null
}
Blocked (only if both fetch and stealth browser fail to return the SSR document):
{
"success": false,
"query": "iphone",
"result_count": 0,
"listings": [],
"error_reasoning": "Cloudflare challenge returned instead of __NEXT_DATA__ document"
}