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.
| Call | What it gives you | How often |
|---|---|---|
| GET /api/item/prices | The 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/history | Every 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-summary | One 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=0 | Steam’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 "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 areavg,min,max,last,sold,bidand the opt-inliquidity. An excluded field is absent from the block; a field that is present andnullmeans 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 asfields=is ignored in multi-window mode.windows=andperiod=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.
| Field | Where it is | What it counts |
|---|---|---|
| observedDays | The envelope, and every entry of the markets roster | Days of archive behind the response as a whole, and behind each market separately. |
| avgDays | Every market block, and every window block inside it | Days the listing average actually rests on. |
| soldDays | Every market block | Days that carried a sold count. Frequently fewer than the priced days. |
| bidDays | Every market block | Days 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.
| Field | Endpoint | What it measures |
|---|---|---|
| avgListingPrice | Price history, either route | The mean of the cheapest ask observed once a day. What you would have paid. Not volume-weighted and not intraday. |
| avgSalePrice | Sales summary | The mean of what the market says actually sold, over its own trailing window. What the market cleared at. |
| avgBidPrice, lastBidPrice | Price history, either route | The 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:
{
"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 see | On | It means |
|---|---|---|
| soldTotal: null | Price history | This market publishes no sold count that can be summed into a window total. Permanent for most of them, and never a zero. |
| avgSalePrice: null | Sales summary | The market sold none of this item in that window. An observation, always paired with volume: 0. |
| bidPrice: null | Price history | This market publishes no bid we can read. Not an empty order book. |
| no key in markets | Cross-market price history | This 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.
| Quantity | Markets that carry it | Why it stops there |
|---|---|---|
| Listing price | Every market with a live price feed | We observe the cheapest ask ourselves, once a day, per market. |
| Sold count | Steam, CSDeals, Skinport and DMarket | Ten 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. |
| Bid | Steam, DMarket, Tradeit, RustSkins and Mannco | Five 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 "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.
nextSinceis 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.