BUFF sale history is now available - POST /v1/archive/buff returns sale prices and daily total supply since September 2024.

How to Scrape Official Steam Community Market Sale History

Updated

Endpoints used: POST /v1/archive/steam

Related pages: Steam price API

Valve's official Steam Community Market embeds median completed-sale prices and purchase counts in item listing pages. The pages are public, but the history interface is undocumented and is not an official API. Match the exact market_hash_name, and store recent hourly points separately from older daily points.

Fetch the exact listing page#

Build https://steamcommunity.com/market/listings/730/<market_hash_name> with the complete URL-encoded name. Request a declared country, language, and currency, and retain the final URL after redirects.

The maintained cs2.sh collector sends the normal bMarketOptOut=1 preference cookie so the classic Market page remains tied to one exact name. This matters because newer Market pages can redirect several related names into a grouped G... page. A group page may embed history for a member other than the one originally requested.

You can inspect the classic page from a terminal. This is an HTML request, so save it instead of piping it directly into a JSON tool:

bash
curl --fail --silent --show-error --location \
  --cookie 'bMarketOptOut=1' \
  --get 'https://steamcommunity.com/market/listings/730/AK-47%20%7C%20Redline%20%28Field-Tested%29' \
  --data-urlencode 'cc=US' \
  --data-urlencode 'l=english' \
  --data-urlencode 'currency=1' \
  --output listing.html

Do not treat a downloaded file as success until it contains the expected classic-page markers and history for the requested name. Steam can return a stripped page or interstitial with HTTP 200.

Parse either history format#

Classic pages expose a JavaScript line1 array. Each row contains a display timestamp, Steam median completed-sale price, and purchase count.

Newer server-rendered pages can embed serialized queryData. Find the query whose key is exactly ['market', 'pricehistory', 730, '<market_hash_name>'], then read ecurrency and the prices array. Each price object contains time, price_median, and purchases.

Never select the first price-history query on the page. Match all four key components and reject the response if the exact query is absent. This prevents a grouped page from silently assigning another item's sales to the requested name.

Compare the two response shapes#

A classic row can look like ["Jun 11 2025 01: +0",2.583,"101"]: display timestamp, median completed-sale price, then purchase count. The count is a string. Keep the unrounded price until currency validation and storage are complete.

The equivalent server-rendered data is shaped like {"ecurrency":1,"prices":[{"time":1779566400,"price_median":41.32,"purchases":27}]}. Here, time is epoch seconds and both price and purchases are numeric. Store the raw form because Steam can change which representation a listing page serves.

Parse the classic history with Node.js#

The following Node.js 20 example fetches one classic page, rejects redirects to another item, validates the US page context, and converts line1 into typed rows. Save it as steam-sale-history.mjs and run node steam-sale-history.mjs.

js
const marketHashName = "AK-47 | Redline (Field-Tested)";
const listingUrl = new URL(
  `https://steamcommunity.com/market/listings/730/${encodeURIComponent(marketHashName)}`,
);
listingUrl.search = new URLSearchParams({ cc: "US", l: "english", currency: "1" });

const response = await fetch(listingUrl, {
  headers: {
    accept: "text/html",
    cookie: "bMarketOptOut=1",
    referer: "https://steamcommunity.com/market/",
  },
  redirect: "follow",
  signal: AbortSignal.timeout(30_000),
});
const html = await response.text();

if (response.status === 429) throw new Error("Steam rate limited the request");
if (!response.ok) throw new Error(`Steam returned HTTP ${response.status}`);

const finalUrl = new URL(response.url);
const finalName = decodeURIComponent(finalUrl.pathname.split("/")[4] ?? "");
if (finalUrl.hostname !== "steamcommunity.com" || finalName !== marketHashName) {
  throw new Error(`Steam resolved the request to ${response.url}`);
}
if (!html.includes("market_listing_nav") && !html.includes("Market_LoadOrderSpread(")) {
  throw new Error("Steam returned an unrecognized or stripped listing page");
}

const country = html.match(/g_strCountryCode\s*=\s*["']([A-Za-z]{2})["']/)?.[1];
if (country?.toUpperCase() !== "US") {
  throw new Error(`Expected a US page context, received ${country ?? "none"}`);
}

const line1 = html.match(/var\s+line1\s*=\s*(\[[\s\S]*?\])\s*;/)?.[1];
if (!line1) throw new Error("Exact classic price history is missing");

const monthNumber = new Map(
  ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
    .map((month, index) => [month, index]),
);

function parseClassicTime(value) {
  const match = /^(\w{3}) (\d{1,2}) (\d{4}) (\d{1,2}): \+0$/.exec(value);
  if (!match || !monthNumber.has(match[1])) {
    throw new Error(`Unexpected Steam timestamp: ${value}`);
  }

  // The classic display is one hour ahead of the corresponding JSON bucket.
  return new Date(
    Date.UTC(Number(match[3]), monthNumber.get(match[1]), Number(match[2]), Number(match[4]))
      - 60 * 60 * 1_000,
  );
}

const observedAt = new Date();
const observedHour = Math.floor(observedAt.getTime() / 3_600_000) * 3_600_000;
const hourlyCutoff = observedHour - 31 * 24 * 3_600_000;
const sourceRows = JSON.parse(line1);
const rows = sourceRows.map((row, index) => {
  if (!Array.isArray(row) || row.length < 3) {
    throw new Error(`Malformed history row at index ${index}`);
  }
  const bucket = parseClassicTime(row[0]);
  const price = Number(row[1]);
  const purchases = Number(row[2]);
  if (price < 0 || purchases < 0 || !Number.isFinite(price) || !Number.isSafeInteger(purchases)) {
    throw new Error(`Invalid price or purchase count at index ${index}`);
  }

  return {
    market_hash_name: marketHashName,
    bucket: bucket.toISOString(),
    interval: bucket.getTime() >= hourlyCutoff ? "1h" : "1d",
    price_median: price,
    purchases,
    currency: "USD",
    observed_at: observedAt.toISOString(),
    source_format: "classic_line1",
  };
});

console.log(JSON.stringify(rows.slice(-5), null, 2));

This example deliberately fails on a beta queryData page instead of guessing. A production parser that supports the beta format should decode its serialized JSON, find a query whose key exactly equals ["market", "pricehistory", 730, marketHashName], validate ecurrency === 1, and only then accept its prices array. Recursive "first history object" matching is unsafe on grouped pages.

Preserve Steam's bucket meaning#

Steam's recent graph points are hourly while older aligned points are daily. Save a bucket interval with every row rather than treating every timestamp as equal-resolution data. Keep:

  • Marketplace bucket time.
  • Collection time.
  • Median sale price.
  • Purchase count.
  • Currency identifier.
  • Exact market hash name.
  • Raw source format, classic or embedded query data.

The current boundary is a rolling 31-day hourly window, floored to the observation hour. Older buckets are daily UTC points. For the classic line1 format, Steam's displayed timestamp is one hour ahead of the corresponding JSON bucket, so normalize that offset before testing daily alignment or generating stable row IDs.

A median is not an OHLC candle, and purchase count is not active listing quantity. Missing history can mean an item never sold, the page format changed, the wrong group member loaded, or access failed. Those cases need different error records.

Plan for page access failures#

Steam listing pages can serve redirects, HTML interstitials, or pages with the market history removed. A normal HTTP 200 therefore does not prove success. Require a recognizable listing-page structure and the exact history key before publishing rows.

Use low request rates, cache full historical responses, and refresh only the recent window after the initial backfill. Stop and back off on 403 or 429 rather than increasing pressure.

Use the retained archive instead#

POST /v1/archive/steam provides Steam's native median price and purchase volume daily from April 26, 2013 and hourly from May 9, 2026. It removes page parsing, grouped-item checks, backfill storage, and recurring refresh work.

The raw page remains free and useful for research. At catalog scale, the difficult part is repeatedly obtaining the correct page, proving its identity, separating hourly from daily buckets, and refreshing it without losing history. cs2.sh is for teams that want the retained Steam dataset without making page access and parser changes part of their product operations.

The same data from cs2.sh#

POST /v1/archive/steam returns the identical native series as typed JSON, with no page parsing and no grouped-item checks.

bash
curl -X POST https://api.cs2.sh/v1/archive/steam \
  -H "Authorization: Bearer <<YOUR_API_KEY>>" \
  -H "Accept-Encoding: gzip" --compressed \
  -H "Content-Type: application/json" \
  -d '{
  "items": ["USP-S | Printstream (Factory New)"],
  "start": "2013-04-26",
  "interval": "1d"
}'
json
"data": [
  { "bucket": "2025-01-01T00:00:00Z", "price": 150.23, "volume": 7 }
]
Property Value
Coverage Daily from April 26, 2013; hourly from May 9, 2026
Intervals 1h, 1d, neither with a maximum request range
Refresh About 1-4x per day
Items per request 100
Access Requires a Scale or Enterprise API key

price is Steam's median sale price and volume is the purchase count, the same two values the page graph draws. Both are independently nullable when Steam's native bucket omits one. Valid items with no rows return not_in_archive; a request where no valid item has rows returns 404 not_found.

Disclosure: this free DIY guide is published by cs2.sh. Valve does not publish this as a supported data API, and the Steam Subscriber Agreement restricts automation. Verify that your intended use is authorized. Tutorial code was reviewed against current collector behavior on August 25, 2026.