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

*Catalog & data quality · Intermediate · Updated 2026-09-25*

Almost every CS2 tool needs a local copy of the item catalog: the list of every skin, sticker, case, and agent, with images, rarities, collections, float ranges, and the ids each marketplace uses for the same item. Prices change by the second, but the catalog changes rarely, mostly when a game update adds a case or a collection. That difference is the whole design of a good sync: download the catalog once, then fetch only what changed.

SkinPricer serves the catalog as the item schema: one versioned snapshot at [`GET /v1/schema`](/api-docs/schema), a single-item lookup at [`/v1/schema/items/:idOrName`](/api-docs/schema-item), and an incremental feed at [`/v1/schema/changes`](/api-docs/schema-changes). All three are included from the Developer plan up. This guide builds a mirror that stays current for a few hundred bytes on a quiet day.

## What is in the schema

The snapshot maps every tracked `market_hash_name` to a record, plus three shared registries (rarities, collections, containers) that items reference by key, so collection art and rarity colors are stored once rather than on every item. Records are sparse: a field that does not apply is omitted, never set to null. A sticker has no `floatRange`, and a case has a `releaseDate` but no `weapon`.

| Field | Why you want it |
| --- | --- |
| `id` | The canonical item id, the same one pricing, NBBO, and liquidity responses return. It is your join key between catalog and prices. |
| `floatRange` | `{ min, max }` float bounds for items that have wear. Essential for float-aware pricing and trade-up tools. |
| `wears`, `hasStattrak`, `hasSouvenir` | Which exteriors and variants exist for the base item, so you can build variant pickers without guessing. |
| `variants` | Doppler and Gamma Doppler phase rows under a base item, each with its own priceable `marketHashName`. |
| `collections`, `containers` | Keys into the registries: which collection an item belongs to and which cases drop it. |
| `ids` | Each marketplace's own catalog id for the item, which is what you need to link out to listings or reconcile venues. |
| `image` | An image URL for the item. |

## Step 1: download the snapshot once

The snapshot is precomputed and large (every item in the catalog), so ask for it compressed. The response carries an `ETag` header and a top-level `schemaVersion`. Store both along with the items: the ETag lets you check for changes cheaply (an unchanged catalog answers `304` with no body), and the version is where the change feed will start.

**curl**

```bash
export SKINPRICER_API_KEY="sp_live_•••"   # from your account's API keys page

# Download once and keep the ETag from the response headers.
curl -s --compressed -D headers.txt -o schema.json \
  "https://pricing.skinpricer.com/v1/schema" \
  -H "Authorization: ApiKey $SKINPRICER_API_KEY"
ETAG=$(grep -i '^etag:' headers.txt | cut -d' ' -f2- | tr -d '\r')

# Later: a conditional request returns 304 with no body if nothing changed.
curl -s -o /dev/null -w "%{http_code}\n" --compressed \
  "https://pricing.skinpricer.com/v1/schema" \
  -H "Authorization: ApiKey $SKINPRICER_API_KEY" \
  -H "If-None-Match: $ETAG"
```

**Node (fetch)**

```ts
const BASE = "https://pricing.skinpricer.com/v1";
const AUTH = { Authorization: `ApiKey ${process.env.SKINPRICER_API_KEY}` };

export interface SchemaItem {
  id: string;
  marketHashName: string;
  slug: string;
  category: string;
  floatRange?: { min: number; max: number };
  ids: Record<string, string>;
  image?: string;
  [field: string]: unknown; // sparse: other fields appear only when they apply
}

export interface SchemaSnapshot {
  schemaVersion: number;
  generatedAt: string;
  itemCount: number;
  items: Record<string, SchemaItem>;
}

/** Returns the snapshot and its ETag, or null when `etag` is still current. */
export async function fetchSnapshot(etag?: string) {
  const res = await fetch(`${BASE}/schema`, {
    headers: { ...AUTH, ...(etag ? { "If-None-Match": etag } : {}) },
  });
  if (res.status === 304) return null;
  if (res.status === 503) {
    throw new Error(`Snapshot not ready, retry in ${res.headers.get("retry-after") ?? 60}s`);
  }
  if (!res.ok) throw new Error(`GET /v1/schema failed: ${res.status}`);
  return {
    etag: res.headers.get("etag"),
    snapshot: (await res.json()) as SchemaSnapshot,
  };
}
```

*The schema is not wrapped by SDK 0.3.0 yet, so these samples use plain HTTP. fetch and requests accept gzip automatically.*

Right after a fresh deploy the snapshot can briefly be unavailable; the API then answers `503` with a `Retry-After` header. Wait and retry rather than treating it as an error in your data.

## Step 2: stay current with the change feed

Re-downloading the snapshot to catch one new case is wasteful. The change feed returns only the items added or changed strictly after a `since` value, which is the `schemaVersion` you stored (a millisecond epoch; an ISO-8601 timestamp also works). An item's version only moves when its content actually changes, so on a quiet day the answer is a nearly empty page.

The feed is keyset-paginated. Each page has `hasMore` and a `nextCursor`; pass the cursor back until `hasMore` is false. `limit` sets the page size, from 1 to 5000 (default 1000). When the last page is applied, store that page's `schemaVersion` as your next `since`. Saving it earlier risks skipping items if your process stops halfway.

**Node (fetch)**

```ts
interface SchemaChanges {
  schemaVersion: number;
  since: number;
  count: number;
  hasMore: boolean;
  nextCursor: string | null;
  items: SchemaItem[];
}

/** Apply every change after `since`; returns the version to store next. */
export async function syncChanges(
  since: number,
  upsert: (item: SchemaItem) => Promise<void>,
): Promise<number> {
  let cursor: string | null = null;
  let version = since;
  do {
    const url = new URL(`${BASE}/schema/changes`);
    url.searchParams.set("since", String(since));
    url.searchParams.set("limit", "1000");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, { headers: AUTH });
    if (!res.ok) throw new Error(`GET /v1/schema/changes failed: ${res.status}`);
    const page = (await res.json()) as SchemaChanges;

    for (const item of page.items) await upsert(item); // key by item.id
    version = page.schemaVersion;
    cursor = page.hasMore ? page.nextCursor : null;
  } while (cursor);
  return version; // persist only after every page is applied
}
```

**Python**

```python
import os
import requests

BASE = "https://pricing.skinpricer.com/v1"
AUTH = {"Authorization": f"ApiKey {os.environ['SKINPRICER_API_KEY']}"}


def sync_changes(since: int, upsert) -> int:
    cursor, version = None, since
    while True:
        params = {"since": since, "limit": 1000}
        if cursor:
            params["cursor"] = cursor
        res = requests.get(f"{BASE}/schema/changes", params=params, headers=AUTH, timeout=10)
        res.raise_for_status()
        page = res.json()
        for item in page["items"]:
            upsert(item)
        version = page["schemaVersion"]
        cursor = page["nextCursor"] if page["hasMore"] else None
        if not cursor:
            return version
```

**Response 200** (`GET /v1/schema/changes`)

```json
{
  "schemaVersion": 1783069455007,
  "since": 1782900000000,
  "count": 1,
  "hasMore": false,
  "nextCursor": null,
  "items": [
    {
      "id": "cmlofbol90hb801yozu37irb8",
      "marketHashName": "Fever Case",
      "slug": "fever-case",
      "category": "container",
      "releaseDate": "2025-02-04T00:00:00.000Z",
      "image": "https://community.fastly.steamstatic.com/economy/image/...",
      "ids": { "itrade": "ZG39o2", "swapgg": "68e9842efed1b98d8ba01b23:5" }
    }
  ]
}
```

*A typical delta: one new container, no further pages, and the version to store for the next poll.*

Pages can also carry a `registries` sidecar with any rarity, collection, or container entries the returned items reference. Merge it into your local registries so a new collection's name and image arrive together with its first item.

> **A sensible schedule:** Poll the change feed every few hours and after major game updates, and run a conditional `If-None-Match` request against the snapshot once a day as a consistency check. Both are cheap, and each costs one request from your plan's budget.

## Step 3: look up a single item

When a price row arrives for an item you have not seen yet, you do not need the whole snapshot. The item lookup accepts either the canonical `id` that the pricing endpoints return or a URL-encoded `market_hash_name`, and returns that item's record plus only the registry entries it references.

```bash
curl -s "https://pricing.skinpricer.com/v1/schema/items/AK-47%20%7C%20Redline%20(Field-Tested)" \
  -H "Authorization: ApiKey $SKINPRICER_API_KEY"
```

## Catalog with prices: the item list endpoints

The schema is metadata only. If you want the catalog and its current prices in one read, there are two options. [`GET /v1/items/all`](/api-docs/items-all) returns every tracked item as one bare JSON array (capped at 50,000 rows) with the latest price per market and a cross-market aggregate; it is heavy, so cache it. For filtered or interactive reads, the paginated [`GET /v1/items`](/api-docs/items) search returns a full pricing snapshot per result.

```ts
// npm i @skinpricer/sdk
import { SkinpricerClient, formatUsd } from "@skinpricer/sdk";

const client = new SkinpricerClient({
  apiKey: process.env.SKINPRICER_API_KEY!,
  authScheme: "ApiKey",
});

const everything = await client.items.all({ timeoutMs: 60_000 }); // large response
const priced = everything.filter((item) => item.priceAggregates?.minPrice != null);
console.log(`${priced.length} of ${everything.length} items have a live price`);

const first = priced[0];
if (first?.priceAggregates?.minPrice != null) {
  console.log(first.name, formatUsd(first.priceAggregates.minPrice));
}
```

- Join on `id` where you can. It is the canonical key that the schema and every pricing, NBBO, and liquidity response share.
- Do not treat a missing field as a bug. Sparse records mean a field is absent when it does not apply to that item category.
- Rust has its own catalog. The schema routes also exist under `/v2` with a required `game` parameter, and change cursors are bound to the game they came from. See [Games: CS2 & Rust](/api-docs/games).

## When to reach for a different endpoint

Once the catalog is mirrored, prices come from the pricing endpoints: the [live CS2 skin prices guide](/resources/live-cs2-skin-prices) covers per-item snapshots, and the [comparison site guide](/resources/build-cs2-price-comparison-site) shows the catalog and prices working together on a real page. If you are using `floatRange` to price specific floats, continue with [how to price a CS2 skin by float range](/resources/cs2-float-range-prices), which pairs the schema bounds with per-bucket prices.

## Endpoints used

- [GET /v1/schema](/api-docs/schema.md)
- [GET /v1/schema/changes](/api-docs/schema-changes.md)
- [GET /v1/schema/items/:idOrName](/api-docs/schema-item.md)
- [GET /v1/items/all](/api-docs/items-all.md)
- [GET /v1/items](/api-docs/items.md)

## Related guides

- [How to build a CS2 skin price comparison site](/resources/build-cs2-price-comparison-site.md)
- [How to price a CS2 skin by its float range](/resources/cs2-float-range-prices.md)
- [How to get live CS2 skin prices from every marketplace](/resources/live-cs2-skin-prices.md)
