Skip to main content

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.

Updated 6 min readIntermediatecurl · TypeScript · PythonMarkdown
On this page
  1. The architecture: your server in the middle
  2. Step 1: search the catalog
  3. Step 2: build the side-by-side price table
  4. Step 3: show which markets are healthy
  5. Finishing touches that set a site apart
  6. When to reach for a different endpoint

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.

PageEndpoint your server callsCache for
Search resultsGET /v1/items with search, page, perPageA few minutes per query
Item detail tableGET /v1/pricing/:marketHashNameAbout a minute per item
Market status badgesGET /v1/markets/health, the one keyless endpointAbout a minute, site-wide
Freshness banner (optional)GET /v1/status/freshnessAbout 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}")
The item name is a path segment, so URL-encode it (the SDK does this for you).
Response 200GET /v1/pricing/:marketHashName
Field reference
{
  "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"
}
The documented response: per-market asks in markets[], bids in buyOrders[], and the cross-market aggregate.
Live
Try it live

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.

Node (SDK)
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 fetchedAt age 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 markets CSV (for example csfloat,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

Run this guide against live data

Plans start at $50/month on the Developer tier. Want to try first? Ask in our Discord for a free 2-day Developer key.