How to build a CS2 skin price comparison site
Build a CS2 skin price comparison site: search the catalog, show every market's price side by side, and surface market health and data freshness.
On this page
A price comparison site answers one question for a visitor: where is this skin cheapest right now, and can I trust that number? Building one yourself usually means maintaining a scraper per marketplace, normalizing currencies, and chasing format changes forever. With a cross-market API the scraping disappears, and the work that remains is the part that makes a comparison site good: search, a clear side-by-side table, and honest freshness signals.
This guide walks through the three pages every comparison site needs (search, item detail, and a market status badge) and the server-side architecture that keeps your API key private and your request budget intact. Every price the API returns is an integer number of USD cents, normalized across venues, so a table sorts correctly without currency conversion.
The architecture: your server in the middle#
Your visitors' browsers should never call the pricing API directly. The key would be visible in the page, anyone could copy it, and every visitor would spend your quota. Put a small server route in between: the browser asks your server, your server asks SkinPricer (with the key from an environment variable), caches the answer, and returns only the fields the page needs.
| Page | Endpoint your server calls | Cache for |
|---|---|---|
| Search results | GET /v1/items with search, page, perPage | A few minutes per query |
| Item detail table | GET /v1/pricing/:marketHashName | About a minute per item |
| Market status badges | GET /v1/markets/health, the one keyless endpoint | About a minute, site-wide |
| Freshness banner (optional) | GET /v1/status/freshness | About a minute, site-wide |
Step 1: search the catalog#
The item search endpoint pages through the tracked catalog and returns a full cross-market snapshot for every result, so a results page can show a price for each row without a second request per item. The search parameter is tokenized: each word is matched as a case-insensitive prefix and every word must match, across the name, base name, slug, weapon, pattern, and rarity. So redline field (or field redline) narrows to Field-Tested Redlines rather than looking for that exact string. Abbreviations are not expanded: ft is not a prefix of any word in "Field-Tested", so it matches nothing. perPage goes up to 100 (default 10).
export SKINPRICER_API_KEY="sp_live_•••" # server-side only, never in the browser
curl -s "https://pricing.skinpricer.com/v1/items?search=redline%20field&perPage=20" \
-H "Authorization: ApiKey $SKINPRICER_API_KEY"// npm i @skinpricer/sdk (runs on your server)
import { SkinpricerClient } from "@skinpricer/sdk";
const client = new SkinpricerClient({
apiKey: process.env.SKINPRICER_API_KEY!,
authScheme: "ApiKey",
});
export async function searchItems(query: string, page = 1) {
const res = await client.items.search({ search: query, page, perPage: 20 });
return {
results: res.data.map((item) => ({
name: item.name,
lowestAskCents: item.bestAsk?.price ?? null,
lowestAskMarket: item.bestAsk?.market ?? null,
markets: item.marketCount ?? item.markets.length,
})),
nextPage: res.pagination.nextPage,
};
}Walk pages with pagination.nextPage, which is null on the last page. For a site that pre-renders a page per item, do not crawl search results to build your item list: sync the catalog once and keep it current with change cursors, as described in the catalog sync guide.
Step 2: build the side-by-side price table#
The item page is the heart of the site. GET /v1/pricing/:marketHashName returns every market's lowest ask in markets[], standing buy orders in buyOrders[], an aggregate block (min, max, average, median, and standard deviation), and the cross-market best ask and best bid. Each market row carries its own fetchedAt, ingestedAt, quote size, and isStale flag, which is exactly what a trustworthy table needs to show.
import { SkinpricerClient } from "@skinpricer/sdk";
const client = new SkinpricerClient({
apiKey: process.env.SKINPRICER_API_KEY!,
authScheme: "ApiKey",
});
export interface ComparisonRow {
market: string;
askCents: number;
listings: number | null;
stale: boolean;
fetchedAt: string;
}
const TTL_MS = 60_000;
const cache = new Map<string, { at: number; rows: ComparisonRow[] }>();
/** Every market's ask for one item, cheapest first, cached for a minute. */
export async function comparePrices(name: string): Promise<ComparisonRow[]> {
const hit = cache.get(name);
if (hit && Date.now() - hit.at < TTL_MS) return hit.rows;
const snapshot = await client.pricing.get(name);
const rows = snapshot.markets
.map((m) => ({
market: m.market,
askCents: m.price,
listings: m.quoteSize ?? null,
stale: m.isStale ?? false,
fetchedAt: m.fetchedAt,
}))
.sort((a, b) => a.askCents - b.askCents);
cache.set(name, { at: Date.now(), rows });
return rows;
}import os
from urllib.parse import quote
import requests
name = "AK-47 | Redline (Field-Tested)"
res = requests.get(
f"https://pricing.skinpricer.com/v1/pricing/{quote(name, safe='')}",
headers={"Authorization": f"ApiKey {os.environ['SKINPRICER_API_KEY']}"},
timeout=10,
)
res.raise_for_status()
snapshot = res.json()
for row in sorted(snapshot["markets"], key=lambda m: m["price"]):
flag = " (stale)" if row.get("isStale") else ""
print(f"{row['market']:<16} {row['price'] / 100:>9.2f}{flag}"){
"canonicalItemId": "cmlofca920lka01yozajhixt3",
"name": "Glock-18 | Water Elemental (Factory New)",
"aggregate": {
"minPrice": 6500,
"maxPrice": 8012,
"avgPrice": 7102,
"medianPrice": 7119,
"stdDev": 439.12,
"marketCount": 10,
"freshMarketCount": 9,
"listingCount": 710,
"updatedAt": "2026-06-23T21:59:48.717Z"
},
"markets": [
{
"market": "csfloat",
"price": 6500,
"currency": "USD",
"fetchedAt": "2026-06-23T21:52:27.427Z",
"ingestedAt": "2026-06-23T21:55:56.923Z",
"quoteSize": 132,
"ingestLatencyMs": 209496,
"isStale": false,
"sourceUpdatedAt": "2026-06-23T21:01:13.000Z",
"sourceTimeKind": "last_modified",
"sourceAgeSeconds": 3074
}
],
"buyOrders": [
{
"market": "buff163",
"price": 6440,
"currency": "USD",
"fetchedAt": "2026-06-23T21:59:48.717Z",
"ingestedAt": "2026-06-23T21:59:48.000Z",
"quoteSize": 47,
"totalQuantity": 47,
"ingestLatencyMs": 0,
"isStale": false
}
],
"bestAsk": {
"market": "csfloat",
"price": 6500,
"updatedAt": "2026-06-23T21:52:27.427Z",
"isStale": false
},
"bestBid": {
"market": "buff163",
"price": 6440,
"updatedAt": "2026-06-23T21:59:48.717Z",
"isStale": false
},
"spreadBps": 93,
"marketCount": 10,
"freshMarketCount": 9,
"calculatedAt": "2026-06-23T22:07:13.752Z"
}markets[], bids in buyOrders[], and the cross-market aggregate.Fetch the live cross-market snapshot for AK-47 | Redline (Field-Tested).
A few presentation rules make the table honest. Show stale rows, but grey them out and label them, since an old quote may still be the only one on a thin item. Show the listing count next to each price so a single cheap listing does not look like deep supply. And keep asks and bids in separate columns or sections: an ask is what a buyer pays, a bid is what a seller receives, and mixing them in one sorted list confuses both audiences.
Step 3: show which markets are healthy#
A comparison site earns trust by admitting when a source is lagging. GET /v1/markets/health is the one endpoint that needs no API key. It returns every integrated marketplace with a status of live, warn, or stale, the time of its last ingested event, its listing count, and a healthBars array for a 24-hour sparkline. Its id field is the same market id you see in pricing rows, so joining the two is a map lookup.
import { SkinpricerClient } from "@skinpricer/sdk";
const client = new SkinpricerClient({
apiKey: process.env.SKINPRICER_API_KEY!,
authScheme: "ApiKey",
});
/** market id -> live | warn | stale, for the badge next to each table row. */
export async function marketStatuses(): Promise<Map<string, string>> {
const rows = await client.markets.health(); // keyless endpoint, no quota used
return new Map(rows.map((row) => [row.id, row.status]));
}The health endpoint is limited by IP address rather than by your key, at roughly 60 requests a minute, so fetch it on your server once a minute and share the result across every page. For an ops dashboard or a site-wide banner, the key-gated freshness endpoint adds a per-market delay_seconds and an overall_status roll-up; the data freshness guide shows how to combine all of these signals.
Finishing touches that set a site apart#
- Format money once, at render time: divide cents by 100 in the view layer and keep integers everywhere else, so totals never drift.
- Show the
fetchedAtage of each row ("2 min ago") rather than hiding it. Visitors trust a table more when they can see how fresh it is. - Offer a markets filter. The pricing endpoint accepts a
marketsCSV (for examplecsfloat,buff163) to restrict a snapshot to the venues a visitor can actually use. - Link each row to the marketplace listing page, and let visitors sort by price, listing count, or freshness.
When to reach for a different endpoint#
If the page only needs the single best price per side, the NBBO is lighter than the full snapshot; the best bid and ask guide covers it. For bulk lowest asks across a watchlist of up to 100 items per call, use the min-prices aggregation. For item metadata (images, float ranges, collections) without prices, mirror the item schema. And for a walkthrough of the snapshot fields themselves, see how to get live CS2 skin prices.
Endpoints used in this guide
Plans start at $50/month on the Developer tier. Want to try first? Ask in our Discord for a free 2-day Developer key.
Related guides
- Prices & quotesBeginner
How to get live CS2 skin prices from every marketplace
Pull live CS2 skin prices from every major marketplace in one call: per-market asks, bids, and aggregate stats in USD cents, with runnable curl and TypeScript.
GET /v1/pricing/:marketHashNameGET /v1/items6 min read - Catalog & data qualityIntermediate
How to sync the full CS2 item catalog with float ranges and images
Sync every CS2 item with float ranges, collections, images, and per-market ids, then keep it current with ETags and incremental change cursors.
GET /v1/schemaGET /v1/schema/changes6 min read - Catalog & data qualityBeginner
How to check CS2 market data freshness before you trust a price
Check CS2 market data freshness before you trust a price: per-market ingest status, latency percentiles, stale flags, and marketplace health.
GET /v1/status/freshnessGET /v1/status/market-latency7 min read