DEV Community

Viktor
Viktor

Posted on

How to Scrape Poshmark with Python: Search Results, Sold Comps, Closets, and a Price-Drop Tracker (2026)

If you resell clothes, you already know the most useful question on Poshmark: what does this actually sell for? Active listings tell you what people hope to get. Sold listings tell you what buyers paid. Put the two side by side in your own database and you can price inventory, spot underpriced items, and see which closets move stock.

Poshmark doesn't offer a public data API. But the website is a JavaScript app, and the data it shows comes from JSON endpoints that answer plain GET requests without a login. In this post I'll build a small Python Poshmark scraper that:

  1. Searches listings with real filters (department, category, size, condition)
  2. Pulls sold listings and turns them into price comps (25th percentile, median, 75th)
  3. Reads a seller's closet and profile stats
  4. Fetches a single listing by URL
  5. Stores every run in SQLite and reports price drops

One file, one dependency (requests). Everything below was run against poshmark.com on October 23, 2026, and the output you see is real.

Before you start: read Poshmark's Terms of Service and robots.txt, keep request rates low, and only collect what you need. Profiles include sellers' real names, so don't store personal data you don't need. The endpoints in this post are internal ones the website uses. They aren't documented and can change without notice.

Your options, briefly

  • HTML pages. Search, listing, and closet pages all embed their data in a window.__INITIAL_STATE__ = {...} script blob (not __NEXT_DATA__). It works, but a search page is about 4 MB of HTML to get 48 listings. Also, robots.txt disallows /search.
  • The vm-rest JSON endpoints (this post). The site fetches its listings from https://poshmark.com/vm-rest/.... Responses are clean JSON. robots.txt doesn't mention /vm-rest (it does disallow /api, /search, /users, and a few others).
  • Headless browser. You don't need one. Everything here worked with plain HTTP from a server.

How a Poshmark search request looks

Here's the part that isn't obvious. The search endpoint doesn't use normal query parameters. You pass a JSON object, URL-encoded, in a single request parameter:

GET https://poshmark.com/vm-rest/posts?request={"filters":{"department":"All","inventory_status":["available"]},"query":"levis 501","experience":"all","count":48}&summarize=true
Enter fullscreen mode Exit fullscreen mode

The response has a data array of listings, a total_listing_count, and a more object with a next_max_id cursor for the next page. Each listing comes with a lot of fields. These are the ones I use:

Field What it is
id 24-character hex id, also the end of the listing URL
title, brand, size, condition The basics (brand is free text, more on that below)
price_amount.val Current price as a string, e.g. "47.0"
original_price_amount.val The "retail" price the seller typed in, often "0.0"
first_user_price_amount.val The price when the item was first listed
inventory.status available or sold_out
inventory.status_changed_at When the status last changed (for sold items: about when it sold)
like_count, comment_count, share_count Engagement
creator_username The seller's closet
first_published_at When it was listed

Step 1: A request helper that knows Poshmark's error style

pip install requests
Enter fullscreen mode Exit fullscreen mode

The first surprise: errors come back as HTTP 200. Ask for a listing that doesn't exist and you get status 200 with a body like this:

{"error":{"errorType":"NotFoundError","statusCode":404, ...},"meta":{"api_url":"/posts/000000000000000000000000"}}
Enter fullscreen mode Exit fullscreen mode

If you only check res.status_code, your scraper will happily treat errors as empty results. So the helper looks inside the body:

import json, re, sqlite3, statistics, sys, time
from datetime import datetime, timezone
from decimal import Decimal

import requests

BASE = "https://poshmark.com/vm-rest"

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    "Accept": "application/json",
})


class PoshmarkError(Exception):
    pass


def get(path, request=None, max_retries=4):
    """GET a vm-rest path. Errors come back as HTTP 200 with an "error" object."""
    params = {}
    if request is not None:
        params = {"request": json.dumps(request, separators=(",", ":")), "summarize": "true"}
    for attempt in range(max_retries):
        res = session.get(f"{BASE}{path}", params=params, timeout=30)
        if res.status_code == 429 or res.status_code >= 500:
            wait = 2 ** attempt * 2
            print(f"{res.status_code} on {path}, sleeping {wait}s", file=sys.stderr)
            time.sleep(wait)
            continue
        res.raise_for_status()
        data = res.json()
        if "error" in data:
            err = data["error"]
            raise PoshmarkError(f'{err.get("errorType")} ({err.get("statusCode")}) for {path}')
        return data
    raise PoshmarkError(f"gave up on {path} after {max_retries} tries")
Enter fullscreen mode Exit fullscreen mode

About rate limits: I sent 30 search requests back to back (about one per second) and got 30 × 200, with no rate-limit headers in the responses. That doesn't mean there's no limit. It means I didn't hit it at that pace. I keep a 1.5-second pause between pages and the retry logic for 429/5xx, in case that changes.

Step 2: The core of the Poshmark scraper, search with filters and paging

These are the filter keys I verified by checking the results that came back:

def build_request(query=None, department="All", category=None, size=None,
                  condition=None, sold=False, sort=None, count=48):
    filters = {"department": department,
               "inventory_status": ["sold_out" if sold else "available"]}
    if category:
        filters["category_v2"] = category
    if size:
        filters["size"] = [size]
    if condition:
        filters["condition"] = [condition]
    req = {"filters": filters, "experience": "all", "count": count}
    if query:
        req["query"] = query
    if sort:
        req["sort_by"] = sort  # e.g. "added_desc", "price_asc", "price_desc"
    return req


def iter_pages(path, request, max_pages=3):
    """Yield listings page by page, following more.next_max_id."""
    for page in range(max_pages):
        data = get(path, request)
        yield from data.get("data", [])
        more = data.get("more") or {}
        if not more.get("is_next_max_id_present"):
            break
        request = {**request, "max_id": more["next_max_id"]}
        time.sleep(1.5)  # be polite


def search(query, max_pages=3, **filters):
    return iter_pages("/posts", build_request(query, **filters), max_pages)
Enter fullscreen mode Exit fullscreen mode

department="Women", category="Jeans", size="28" and condition="nwt" all came back filtered correctly. Paging with max_id gave a second page of 48 with zero overlap with the first. sort_by: "added_desc" returned the newest listings first, and price_asc/price_desc sorted by price.

Things that didn't work the way I expected:

  • Price filters are silently ignored. I tried four plausible shapes ("price": ["25-50"], {"min":25,"max":50}, and others). Every one returned the exact same total_listing_count as no filter at all, with prices from $16 to $100. Poshmark doesn't say "unknown filter". It just ignores it. So I filter by price on my side. (The HTML search page does accept price[]=25-50 in its URL, but that's the /search path robots.txt disallows.)
  • count caps at 150. count: 100 returned 100 listings, count: 200 returned 150.
  • You probably can't page through everything. total_listing_count said 158,172 for "levis 501" and 4.7 million for "nike", but more.total was 5000 for every query I tried. That looks like a depth cap. I didn't page 5,000 deep to confirm it, but plan for narrow, filtered searches instead of "download everything".

Step 3: Clean rows (and three data quirks)

# Condition codes seen in live data. Older listings use "not_nwt", some have none.
CONDITIONS = {"nwt": "New with tags", "uln": "Like new", "ug": "Good",
              "uf": "Fair", "not_nwt": "Used (older listing)"}


def money(obj, zero_is_missing=False):
    if not obj or not obj.get("val"):
        return None
    value = Decimal(obj["val"])
    return None if zero_is_missing and value == 0 else value


def to_row(p):
    inv = p.get("inventory") or {}
    return {
        "id": p["id"],
        "title": p.get("title", "").replace("\u200e", "").strip(),
        "brand": p.get("brand"),
        "size": p.get("size"),
        "condition": CONDITIONS.get(p.get("condition"), p.get("condition") or "n/a"),
        "price": money(p.get("price_amount")),
        "original_price": money(p.get("original_price_amount"), zero_is_missing=True),
        "first_price": money(p.get("first_user_price_amount")),
        "availability": inv.get("status"),
        "availability_changed_at": inv.get("status_changed_at"),
        "likes": p.get("like_count", 0),
        "comments": p.get("comment_count", 0),
        "seller": p.get("creator_username"),
        "listed_at": p.get("first_published_at"),
        "url": f"https://poshmark.com/listing/{p['id']}",
    }
Enter fullscreen mode Exit fullscreen mode

What each workaround is for:

  1. Condition is a code. In a 150-listing "nike" sample I saw ug (96), uln (23), nwt (15), uf (6), and 10 listings with no condition key at all. The labels ("Like New", "Good", "Fair") come from a config blob inside the page HTML. Filtering on not_nwt returned listings whose condition really is not_nwt, which looks like the older used/not-used scheme.
  2. "Retail" price is usually zero. 111 of those 150 listings had original_price_amount of "0.0", meaning the seller left it blank. If you compute "discount from retail" without handling this, you'll divide by zero or report 100% discounts.
  3. Invisible characters in titles. Some sellers' titles contain U+200E (left-to-right mark), e.g. "Levis 501\u200e Jeans...". It's invisible on screen, but it breaks exact matching and deduping, so I strip it.

brand is free text, too. Filtering on "brand": ["Levi's"] changed the result count, but the brands that came back included Levi's 501, Levis 501, and levi's 501. Normalize brands yourself before you group by them.

The short https://poshmark.com/listing/<id> URL works. It returns a 301 redirect to the full slug URL.

Step 4: Sold comps

This is the part resellers care about. Same endpoint, with inventory_status: ["sold_out"]:

def sold_comps(query, max_pages=2, **filters):
    rows = [to_row(p) for p in search(query, max_pages=max_pages, sold=True, **filters)]
    prices = sorted(float(r["price"]) for r in rows if r["price"] is not None)
    if not prices:
        return rows, None
    q = statistics.quantiles(prices, n=4) if len(prices) >= 4 else [prices[0], prices[len(prices) // 2], prices[-1]]
    return rows, {"n": len(prices), "p25": q[0], "median": statistics.median(prices), "p75": q[-1]}
Enter fullscreen mode Exit fullscreen mode

Real output for women's Levi's 501 jeans in size 28:

$ python poshmark_scraper.py comps "levis 501" --department Women --category Jeans --size 28
sold comps for 'levis 501' {'department': 'Women', 'category': 'Jeans', 'size': '28'}: {'n': 96, 'p25': 22.0, 'median': 30.0, 'p75': 40.0}
  sold 2026-10-22  $   45.0  Levi’s 501 She’s Crafty 150th Anniversary Floral Patch Jeans
  sold 2026-10-22  $   33.0  Levi's 501 90s Womens Jeans 28x32 Light Wash Straight Leg De
  sold 2026-10-18  $   30.0  Levi's 501 Light Blue Distressed Straight Women’s Jeans W28 
  sold 2026-10-21  $   44.0  Levi’s 501s
  sold 2026-10-06  $   30.0  Levi's 501 Straight Leg Button Fly Jeans Patchwork Raw Hem W
Enter fullscreen mode Exit fullscreen mode

Read those numbers with care. price_amount on a sold listing is the last listed price. If the buyer paid with an accepted offer, I didn't find that amount anywhere in the response. Your "median sold price" is really a median of list prices at the time of sale, so it's probably a bit high.

Step 5: Closets and single listings

A closet uses the same request format at a different path. The profile is a separate call:

def closet(username, max_pages=3, **filters):
    return iter_pages(f"/users/{username}/posts/filtered",
                      build_request(None, **filters), max_pages)


def listing(id_or_url):
    m = re.search(r"([0-9a-f]{24})/?(?:[?#].*)?$", id_or_url)
    if not m:
        raise ValueError("expected a 24-character listing id or a /listing/ URL")
    return get(f"/posts/{m.group(1)}")  # no "data" wrapper on this one


def user(username):
    return get(f"/users/{username}")["data"]
Enter fullscreen mode Exit fullscreen mode

Watch the response shapes. /users/{username} wraps the profile in data, but /posts/{id} returns the listing at the top level. The profile's aggregates object has the useful numbers: posts, followers, following, shares, seller_orders_shipped, items_sold_display (a rounded string like "100+", not an exact number), and last_active_date.

$ python poshmark_scraper.py closet shopladydeals --pages 1
shopladydeals: 617 listings, 14411 followers, items sold: 100+, last active 2026-10-21
    11 likes  $   19.0  Motherhood Maternity Pants Women's Yoga z Small Gray Full Pa
     4 likes  $   24.0  Duluth Trading Men's Long Sleeve Beige Waffle Henley Shirt S
     3 likes  $   35.0  Tommy Hilfiger Sunglasses for men Matte Black and Tortoise s
Enter fullscreen mode Exit fullscreen mode

The profile also returns the seller's first and last name and some linked-account IDs. My closet command only prints aggregate counts. Do the same unless you have a real reason to keep personal data.

Step 6: A price-drop tracker in SQLite

Poshmark sellers drop prices all the time. To catch drops, save a snapshot on every run and compare each listing with its price in the previous run:

def track(query, db_path="poshmark.db", max_pages=2, **filters):
    key = json.dumps({"q": query, **filters}, sort_keys=True)
    taken_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
    rows = [to_row(p) for p in search(query, max_pages=max_pages, **filters)]
    con = sqlite3.connect(db_path)
    con.executescript(SCHEMA)  # snapshots(taken_at, search_key, id, title, ..., price, availability, likes)
    con.executemany(
        "INSERT OR IGNORE INTO snapshots VALUES (?,?,?,?,?,?,?,?,?,?)",
        [(taken_at, key, r["id"], r["title"], r["brand"], r["size"], r["seller"],
          str(r["price"]) if r["price"] is not None else None, r["availability"], r["likes"])
         for r in rows],
    )
    con.commit()
    changes = con.execute(
        """
        WITH runs AS (
          SELECT DISTINCT taken_at FROM snapshots WHERE search_key = ?
          ORDER BY taken_at DESC LIMIT 2
        ),
        cur  AS (SELECT * FROM snapshots WHERE search_key = ? AND taken_at = (SELECT MAX(taken_at) FROM runs)),
        prev AS (SELECT * FROM snapshots WHERE search_key = ? AND taken_at = (SELECT MIN(taken_at) FROM runs))
        SELECT cur.id, cur.title, prev.price, cur.price
        FROM cur JOIN prev ON cur.id = prev.id
        WHERE (SELECT COUNT(*) FROM runs) = 2 AND CAST(cur.price AS REAL) < CAST(prev.price AS REAL)
        ORDER BY CAST(prev.price AS REAL) - CAST(cur.price AS REAL) DESC
        """, (key, key, key)).fetchall()
    con.close()
    return rows, changes
Enter fullscreen mode Exit fullscreen mode

Run it:

$ python poshmark_scraper.py track "levis 501" --department Women --category Jeans --size 28 --pages 2
96 available listings for 'levis 501' {'department': 'Women', 'category': 'Jeans', 'size': '28'}
  $  16.0 (no retail)       28 Good          501 Levi's denim
  $  16.0 (no retail)       28 Good          Levis Premium 501 Light Wash Straight Leg Raw
  $  17.0 (no retail)       28 Like new      Levi's 501 High Rise Straight Cropped Jeans L
  $  19.0 (retail $75.0)    28 Good          Levi's 501 high waisted skinny women's jeans
  $  19.0 (no retail)       28 Fair          Levi’s 501 Curve Jeans
0 price drops since the previous run
Enter fullscreen mode Exit fullscreen mode

Honest note: I ran it a few times a couple of minutes apart, and it found zero drops, which is what you'd expect over that short a window. Put it in cron once or twice a day and the drop list fills up. --max-price 25 filters on your side (remember that the server ignores price filters). With that flag, the same search came down to 26 listings.

Two design choices worth knowing about:

  • Only listings that appear in both runs are compared. Search results move around, so a listing that drops off page 2 isn't "removed". It's just out of view. Narrow filters with fewer total results make the tracker more reliable.
  • Prices are stored as text so Decimal values round-trip exactly. Cast them in SQL when comparing.

The full script, with a small CLI (track, comps, closet, listing), is about 240 lines.

The walls you'll hit

  • Undocumented endpoints. vm-rest is the website's internal API. The JSON-in-a-query-parameter format, filter names, and response shapes can change at any time. Log loudly when a field you expect is missing.
  • Silent filter failures. An unknown or wrong filter is ignored, not rejected. After every change, check that total_listing_count actually moved.
  • HTTP 200 errors. Check for the error key every time.
  • Search depth. more.total says 5000 even when there are millions of matches. Split big searches by category, size, or condition.
  • Sold prices aren't transaction prices. Accepted offers and bundle discounts aren't visible in what I pulled.
  • Free-text brands and missing fields. Normalize brands, and expect condition or retail price to be missing.
  • Scale. 30 requests at about one per second went through fine from a single server IP. Polling thousands of searches is a different story, and I didn't test it.

When DIY stops being worth it

For a few saved searches checked once or twice a day, the script above is plenty. It gets tedious when you need server-side price filters, many closets, or data the search endpoint doesn't give you, like seller reviews or Posh Parties. Then you're maintaining a reverse-engineered client.

That's the point where I'd use a hosted API. I use ScrapeAtlas* for this. Its GET /v1/poshmark/search takes query, department, category, size, condition, availability (available, sold, or all), sort, minPrice/maxPrice (so price filtering happens on the server), plus cursor and limit:

import os
import requests

def api_sold(query, cursor=None):
    params = {"query": query, "department": "Women", "category": "Jeans",
              "size": "28", "availability": "sold", "maxPrice": "60"}
    if cursor:
        params["cursor"] = cursor
    res = requests.get(
        "https://api.scrapeatlas.com/v1/poshmark/search",
        params=params,
        headers={"x-api-key": os.environ["SCRAPEATLAS_API_KEY"]},
        timeout=60,
    )
    res.raise_for_status()
    data = res.json()
    # data["status"] is "success" or "partial"; data["coverage"]["warnings"] explains gaps
    return data.get("listings", []), data.get("nextCursor")
Enter fullscreen mode Exit fullscreen mode

The listings keep Poshmark's field names (price_amount, original_price_amount, inventory, like_count, ...), so most of to_row() keeps working. There are also /v1/poshmark/listing, /closet, /profile, /love-notes (the five-star reviews on a closet's About page), and /parties with /party-listings.

Wrapping up

  • Skip the HTML. Poshmark's site loads its data from vm-rest JSON endpoints that answer plain GET requests.
  • Send a JSON request parameter, and check that every filter actually changes the result count. Price filters don't.
  • Look for error in 200 responses.
  • Clean up the data: condition codes, "0.0" retail prices, free-text brands, and invisible characters in titles.
  • Sold comps are list prices, not exact transaction prices. They're still the best pricing signal you can get for free.

Found a filter key I missed, or a field that moved? Tell me in the comments and I'll fold it into the script.


*I build it.

Top comments (0)