# 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.

*Build with the API · Intermediate · Updated 2026-09-25*

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`](/api-docs/items) with `search`, `page`, `perPage` | A few minutes per query |
| Item detail table | [`GET /v1/pricing/:marketHashName`](/api-docs/price) | About a minute per item |
| Market status badges | [`GET /v1/markets/health`](/api-docs/markets), the one keyless endpoint | About a minute, site-wide |
| Freshness banner (optional) | [`GET /v1/status/freshness`](/api-docs/status) | About a minute, site-wide |

> **Page views are not API calls:** Without a cache, a traffic spike turns straight into a `429`. Your plan's per-minute and monthly budgets are pooled across every key on the account, so a busy page can starve your other integrations too. Cache for your refresh interval and serve every visitor from the cache.

## 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).

**curl**

```bash
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"
```

**Node (SDK)**

```ts
// 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](/resources/sync-cs2-item-catalog).

## Step 2: build the side-by-side price table

The item page is the heart of the site. [`GET /v1/pricing/:marketHashName`](/api-docs/price) 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.

**Node (SDK)**

```ts
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;
}
```

**Python**

```python
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 200** (`GET /v1/pricing/:marketHashName`)

```json
{
  "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.*

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.

> **Wallet credit is not cash:** Steam Community Market prices are paid in Steam wallet credit, and they usually sit above cash marketplaces for that reason. Label Steam rows clearly rather than ranking them as if they were directly comparable. The [Steam vs cash-market guide](/resources/steam-vs-cash-market-prices) explains how to compare them properly.

## Step 3: show which markets are healthy

A comparison site earns trust by admitting when a source is lagging. [`GET /v1/markets/health`](/api-docs/markets) 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.

```ts
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](/api-docs/status) adds a per-market `delay_seconds` and an `overall_status` roll-up; the [data freshness guide](/resources/cs2-market-data-freshness) 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](/api-docs/nbbo) is lighter than the full snapshot; the [best bid and ask guide](/resources/cs2-best-bid-and-ask) covers it. For bulk lowest asks across a watchlist of up to 100 items per call, use the [min-prices aggregation](/api-docs/aggregations). For item metadata (images, float ranges, collections) without prices, mirror the [item schema](/api-docs/schema). And for a walkthrough of the snapshot fields themselves, see [how to get live CS2 skin prices](/resources/live-cs2-skin-prices).

## Endpoints used

- [GET /v1/items](/api-docs/items.md)
- [GET /v1/pricing/:marketHashName](/api-docs/price.md)
- [GET /v1/markets/health](/api-docs/markets.md)
- [GET /v1/status/freshness](/api-docs/status.md)

## Related guides

- [How to get live CS2 skin prices from every marketplace](/resources/live-cs2-skin-prices.md)
- [How to sync the full CS2 item catalog with float ranges and images](/resources/sync-cs2-item-catalog.md)
- [How to check CS2 market data freshness before you trust a price](/resources/cs2-market-data-freshness.md)
