Loading…

Pricing data

Everything a price model needs, in the order the calls make sense — and the four numbers to read before you trade on any of it.

The call sequence

Five calls cover the whole surface. The first two are catalogue-wide and are where almost every integration lives; the last three answer questions the first two deliberately do not.

CallWhat it gives youHow often
GET /api/item/pricesThe cheapest current offer for every item, with the market holding it, and a liquidityScore per item.Whenever you need a current price.
GET /api/marketplace/prices/historyEvery item, on every market, aggregated over a window: mean, minimum and maximum listing price, sold counts and bids.Once to seed, then with since=.
GET /api/marketplace/{market}/sales-summaryOne market’s own completed-sale statistics, read from the vendor and attributed to them.Per market you care about. The response carries fetchedAt and expiresAt, so polling faster than that only re-reads a cache.
GET /api/item/{id}/sales/market?maxDays=0Steam’s own daily sale volume and median for one item, back to the first sale Steam recorded for it.Per item, when you need depth the cross-market archive does not have.
GET /api/marketplace/prices/history?since=Only the items that moved since your last cursor.Steady state.

The second call is the one worth understanding first. It is the cross-market transpose: one request returns the whole catalogue with every market nested under each item, where the per-market route (GET /api/marketplace/{market}/prices/history) fixes one market and returns every item. Asking the per-market route once per market answers the same question in seventeen requests, and downloads more bytes doing it, because each of those responses repeats the item’s identity.

curl
curl "https://api.scmm.app/api/marketplace/prices/history?markets=Skinport,DMarket,SteamCommunityMarket&fields=avg,sold,bid"

This endpoint defaults to fields=avg. Its per-market sibling does not. A default here is multiplied by every market carrying the item, so the cross-market route ships the averages alone unless you ask for more. A query copied from one route to the other therefore returns a different row. You never have to infer which you got: every response echoes the fields it applied.

Narrowing the response

One call is the whole catalogue, so the parameters that shrink it matter more here than on any other endpoint. They do different jobs and two of them apply in different modes:

  • markets= is the lever that actually shrinks the payload, which is roughly proportional to how many markets carry each item. Naming the three or four you can buy from is the single biggest saving available.
  • fields= narrows each market block in aggregate mode. The groups are avg, min, max, last, sold, bid and the opt-in liquidity. An excluded field is absent from the block; a field that is present and null means no such observation exists.
  • stats= narrows the per-window blocks, and only in multi-window mode (windows=30d,60d,90d). It is ignored in aggregate mode, exactly as fields= is ignored in multi-window mode.
  • windows= and period= choose the window rather than the shape. Asking for several windows multiplies the blocks, not the rows.

A multi-window response is large whatever stats= says. Each window block keeps a flat half that cannot be opted out of, so narrowing the statistics barely moves the size. If a multi-window request is too big, narrow it with markets=, not with stats=.

fields=liquidity is aggregate mode only. If you want several windows and the liquidity score, that is two calls — or read liquidityScore straight off GET /api/item/prices, which carries it on every row. It scores the item as a whole rather than any single market block, because its inputs are Steam’s order book and trailing sales volume: one measurement per item, whichever markets carry it.

Read the day counts before the prices

Every average this API returns ships with the number of days behind it, and none of those numbers can be excluded from a response. They are not diagnostics. They are the difference between a signal and a confident wrong number.

FieldWhere it isWhat it counts
observedDaysThe envelope, and every entry of the markets rosterDays of archive behind the response as a whole, and behind each market separately.
avgDaysEvery market block, and every window block inside itDays the listing average actually rests on.
soldDaysEvery market blockDays that carried a sold count. Frequently fewer than the priced days.
bidDaysEvery market blockDays that carried a bid.

Ask for a 30-, 60- and 90-day window in one request and you may get the same number three times. Each window is capped by how many days we have archived for that market, and capture began well after the markets did. Until a market’s archive is longer than the widest window you asked for, those windows are one number wearing several labels — and avgDays is the only thing on the wire that says which. Compare two windows only when their day counts differ.

Coverage is a property of the market, not of your request, and it varies widely between them: one market may have been archived since the beginning while another joined last week. That is why the day count sits on each block rather than once on the envelope, and why the roster on the envelope carries a per-market observedDays of its own.

The one series that is not bounded this way is Steam’s own sale volume. GET /api/item/{id}/sales/market?maxDays=0 returns one point per day back to the first sale Steam recorded for that item, which for an old item is years rather than weeks. It is a sale volume for one item rather than a listing price for the catalogue, so read it beside these figures rather than in place of them. The default is maxDays=30, which is a default and not a ceiling.

Three prices, three questions

This API publishes three differently-measured prices and gives each of them its own name. They are not interchangeable and no two of them can be averaged, differenced or substituted.

FieldEndpointWhat it measures
avgListingPricePrice history, either routeThe mean of the cheapest ask observed once a day. What you would have paid. Not volume-weighted and not intraday.
avgSalePriceSales summaryThe mean of what the market says actually sold, over its own trailing window. What the market cleared at.
avgBidPrice, lastBidPricePrice history, either routeThe best standing buy order. What you would be paid for selling into that market.

Never merge two of these into one series. The gap between an ask and a bid is the spread you cross to sell; the gap between an ask and a sale is what the market did not clear at. Both gaps are information, and a model that averages the two prices away is measuring neither. The same rule holds across markets: a listing average from one vendor and a sale average from another are two different measurements of two different populations.

One item across three markets, showing all three quantities and both kinds of null side by side:

One element of the response
{
  "id": "fcdd75a8-1d4c-4efa-8460-30d062a42fc7",
  "marketHashName": "Tempered AK47",
  "liquidityScore": 57,
  "markets": {
    "SteamCommunityMarket": {
      "avgDays": 16, "avgListingPrice": 1219,
      "avgBidPrice": 1107, "lastBidCount": 914, "lastBidUnit": "units", "bidDays": 3,
      "soldTotal": 66, "avgSoldPerDay": 11, "soldDays": 6, "soldWindow": "1d"
    },
    "CSDeals": {
      "avgDays": 22, "avgListingPrice": 1033,
      "avgBidPrice": null, "bidDays": 0,
      "soldTotal": 13, "avgSoldPerDay": 1.63, "soldDays": 8, "soldWindow": "1d"
    },
    "Skinport": {
      "avgDays": 22, "avgListingPrice": 1197,
      "avgBidPrice": null, "bidDays": 0,
      "soldTotal": null, "avgSoldPerDay": null, "soldDays": 0, "soldWindow": "24h"
    }
  }
}

Prices are integer hundredths, like everywhere else on this API — divide by 100. Note that Steam rests on fewer days than the other two here: coverage differs per market, and the block says so.

What null means, on both sides

Two opposite facts meet on this API and both are written as null. Reading one as the other is the mistake that turns a coverage limit into an imaginary market crash.

You seeOnIt means
soldTotal: nullPrice historyThis market publishes no sold count that can be summed into a window total. Permanent for most of them, and never a zero.
avgSalePrice: nullSales summaryThe market sold none of this item in that window. An observation, always paired with volume: 0.
bidPrice: nullPrice historyThis market publishes no bid we can read. Not an empty order book.
no key in marketsCross-market price historyThis market published nothing for this item. The roster that was searched is on the envelope, so an absent key never means the market was skipped.

A ?? 0 in your mapper is the bug this section exists to prevent. It turns “this market publishes nothing” into “nobody bought one”, and the two are not close. Where a real zero is possible — volume: 0, a bidCount of 0 on a market that does publish a book — it is published as a zero. There are no estimates anywhere in this API: a number we did not observe is absent or null, never modelled.

Which markets carry what

Listing prices come from our own observation and cover every market we track. The other two quantities come from what each vendor chooses to publish, so their coverage is a fact about the vendor rather than an outage on our side, and it is not going to widen on its own.

QuantityMarkets that carry itWhy it stops there
Listing priceEvery market with a live price feedWe observe the cheapest ask ourselves, once a day, per market.
Sold countSteam, CSDeals, Skinport and DMarketTen of the seventeen markets publish no sold count on any surface, so their soldCount is null permanently. Two more publish one we do not collect nightly, and theirs is null too. The archive also holds a historical series imported from three vendors’ own trade histories: RustTM up to six thousand sales per item, RustSkins the last fifty sales per item and CS.Deals the last five thousand sales per item, the whole ledger on quieter items. For one of them — RustSkins — that series is not maintained, so past its newest dated point soldCount returns to null: that means not collected, never nothing sold.
BidSteam, DMarket, Tradeit, RustSkins and ManncoFive of the remaining twelve are trade bots, which hold their own stock and run no order book — but a bot still quotes a price it will buy at, and TradeitGG publishes one we read. The rest run a book that is not on their public API.

The roster on the envelope lists every market this API can name, including ones we do not currently price. Those report an observedDays of 0 and appear in no item’s markets map, so read the roster entry rather than assuming a name in it means data behind it.

Skinport’s sold count is a trailing 24-hour figure rather than a daily bucket, so it cannot be summed into a window total: soldTotal is null for it in aggregate mode while the raw daily points carry real numbers. That is the general rule and not a Skinport quirk — soldWindow names which number a sold count is, and a trailing window can never be summed, differenced, or divided into a per-day rate.

Staying current

Seed once, then poll the delta. Every cross-market response hands you the cursor for the next one as nextSince; pass it back as since= and you get only the items that moved.

curl
curl "https://api.scmm.app/api/marketplace/prices/history?markets=Skinport,DMarket&since=2026-08-29T00:00:00.000Z"
  • Use the cursor, not your own clock. nextSince is the newest observation across the roster you asked for; a wall clock is not, and the difference is the rows you silently skip.
  • Take the cursor from a request with the same markets= you intend to keep polling. A cursor taken from a wider roster can step past a slower market’s day.
  • An item that moved on any market in your roster comes back with all of its markets: a delta selects items, not cells.
  • A poll that finds nothing is an empty list, not the catalogue again. Polling is cheap; re-seeding is not.
  • The newest daily bucket is provisional and settles overnight, so a window that includes today runs low until it does.

The bulk endpoints are limited to 10 requests / minute each. GET /api/item/prices, the two price-history routes and GET /api/marketplace/{market}/sales-summary each carry their own budget, counted per IP and per endpoint. One cross-market call is already every item on every market, so a poll every few minutes is generous. See Rate limits.

Next

  • Market data reference— every parameter of both history routes and of the sales summary, generated from the live spec.
  • Items reference— the bulk price feed, the deep Steam sale series and the per-item order book.
  • Quickstart— base URL, money, errors and the full rate-limit table.