# Skintick — full API reference for LLM context > REST API for Counter-Strike 2 skin prices across eight marketplaces. > Normalized response shape, cross-market outlier and confidence flags, > explicit freshness on every row. This document is a human-readable mirror of the OpenAPI spec, sized for LLM context windows. The canonical spec lives at https://api.skintick.io/openapi.yaml and is the source of truth for breaking changes; this file lags by one deploy at most. --- ## What Skintick is Skintick is an independent pricing aggregator for CS2 (Counter-Strike 2) skins. It collects price snapshots from eight marketplaces on per-source schedules, stores them in Postgres as BIGINT-cents to avoid float drift, and exposes the normalized data through a REST API. The product wedge over competitors: - **Honest data quality flags.** Every price row includes `is_outlier` (true when this marketplace's price is outside 3× the cross-market median) and `confidence` (tier derived from listings count). Consumers can drop suspicious rows before doing spread math instead of re-implementing the analysis client-side. - **Explicit freshness.** Every latest-price row includes `collected_at`, `source_updated_at`, `staleness_seconds`, and `is_stale`. No pretending prices are fresh when they aren't. - **Per-marketplace health surface.** `/v1/status` exposes durable collector telemetry — last success, last error, run duration — so consumers can spot a degraded source before it bites them. - **Cross-market spread route in one call.** `/v1/spreads` returns the best buy→sell route per item with fees and a coarse trade-lock risk adjustment baked in, plus a `risk_model` envelope that admits the approximation. --- ## Authentication All `/v1/*` endpoints require an API key: Authorization: Bearer skt_<43-char base64url> Keys are minted via Skintick (CLI today, web sign-up coming). Each key has its own per-minute and per-day rate limits. Every response carries: - `X-RateLimit-Limit`: per-minute cap - `X-RateLimit-Remaining`: tokens left in the per-minute bucket - `X-RateLimit-Reset`: unix timestamp when the bucket refills - `X-RateLimit-Limit-Day` / `X-RateLimit-Remaining-Day`: per-day pair Status codes: - `401 missing_key`: no Authorization header - `401 invalid_key`: key not recognized - `403 revoked_key`: key was revoked - `429 rate_limited`: per-minute or per-day cap exceeded The two public endpoints (`/healthz` and `/v1/status`) require no key — they're meant for uptime monitors and status pages. --- ## Conventions **Currency.** All prices are USD. Native EUR/CNY pricing on the source is converted at the boundary so consumers don't need a currency table. **Prices.** Decimal strings (`"22.40"`) in JSON, integer cents in storage. Never a float — float drift would silently corrupt spread math. **Identifiers.** Items are keyed by Steam's `market_hash_name` (e.g. `"AK-47 | Redline (Field-Tested)"`, with wear in parens). URL-encode in path parameters: spaces become `%20`, `|` becomes `%7C`, parens become `%28`/`%29`. **Timestamps.** RFC 3339 in UTC. Example: `"2026-05-14T17:42:08Z"`. **Pagination.** Item-list uses opaque cursor pagination (base64url over an internal id) so the page is stable as new items are added. Spreads uses opaque offset cursor. **Errors.** Consistent envelope: { "error": { "code": "invalid_cursor", "message": "cursor is malformed" } } --- ## Endpoint reference ### `GET /healthz` Liveness check. Pings the database and returns `200 {"status":"ok"}` when both API and Postgres are reachable. Returns `503 db_unreachable` otherwise. No auth. ### `GET /v1/status` Returns the latest collector run outcome for every configured marketplace. No auth. CORS open. Response: { "markets": [ { "market": "skinport", "market_type": "p2p", "status": "healthy", "last_success_at": "2026-05-14T12:00:00Z", "last_error_at": null, "last_error": null, "last_run_duration_ms": 16525 }, ... ] } Status values: - `healthy`: latest run succeeded - `degraded`: latest run failed but a previous one succeeded - `unavailable`: every recorded run failed - `unknown`: no runs on record yet (e.g. parked marketplace) ### `GET /v1/markets` Lists all configured marketplaces with fees and trade-lock days. Response: { "data": [ { "slug": "skinport", "display_name": "Skinport", "type": "p2p", "buyer_fee_pct": 0, "seller_fee_pct": 12, "trade_lock_days": 0 }, ... ] } `type` is either `p2p` (listing-driven marketplace) or `instant_bot` (bot inventory where prices carry an instant-trade premium). ### `GET /v1/items?q=&limit=&cursor=` Paginated catalog of items Skintick has ever seen. Stable cursor-based pagination sorted by internal id ASC. Parameters: - `q`: case-insensitive substring filter on `market_hash_name` - `limit`: 1–200, default 50 - `cursor`: opaque cursor from a previous response's `next_cursor` Response: { "data": [ { "market_hash_name": "AK-47 | Aphrodite (Battle-Scarred)", "first_seen_at": "2026-05-13T23:38:49Z", "last_seen_at": "2026-05-15T16:27:40Z" }, ... ], "next_cursor": "MTE" } `next_cursor` is omitted on the last page. ### `GET /v1/items/{market_hash_name}` Returns one row per marketplace with the latest snapshot for the requested item. Rows include quality and freshness fields. Response: { "market_hash_name": "AK-47 | Redline (Field-Tested)", "prices": [ { "market": "skinport", "market_type": "p2p", "currency": "USD", "min_price": "32.79", "max_price": "20532.60", "median_price": "53.36", "mean_price": "196.22", "suggested_price": "41.21", "listings_count": 546, "collected_at": "2026-05-14T12:00:00Z", "source_updated_at": "2026-05-14T11:55:13Z", "is_outlier": false, "confidence": "high" }, ... ] } `is_outlier` requires at least three priced p2p marketplaces to fire; below that floor it's always false. `max_price` is sometimes a historical extreme rather than a current top-of-book — Skinport in particular reports all-time max. ### `GET /v1/items/{market_hash_name}/history` Time series of snapshots for one (item, marketplace). Parameters: - `market` (required): marketplace slug - `from`, `to`: RFC 3339 bounds, optional - `limit`: 1–5000, default 500 Response: { "market_hash_name": "AK-47 | Redline (Field-Tested)", "market": "skinport", "data": [ { "collected_at": "2026-05-13T23:38:49Z", "min_price": "32.98", "listings_count": 531, "source_updated_at": "2026-05-13T23:35:12Z" }, ... ] } Ordered by `collected_at` ASC. ### `POST /v1/prices/latest` Batch latest-price lookup. The recommended shape for bots and dashboards that need many quotes per cycle. Request: { "items": [ "AK-47 | Redline (Field-Tested)", "Glove Case", "Revolution Case" ], "markets": ["skinport", "csfloat"] } - `items`: 1–100 market_hash_names. Duplicates and empty strings are silently dropped. - `markets`: optional marketplace-slug filter. Unknown slugs are reported under `warnings` instead of failing the request. Response: { "data": [ { "market_hash_name": "AK-47 | Redline (Field-Tested)", "prices": [ { "market": "skinport", "market_type": "p2p", "price": "32.79", "currency": "USD", "listings_count": 546, "collected_at": "2026-05-14T12:00:00Z", "source_updated_at": "2026-05-14T11:55:13Z", "staleness_seconds": 142, "is_stale": false, "is_outlier": false, "confidence": "high" } ] } ], "missing": ["Revolution Case"], "warnings": [ { "code": "unknown_market", "message": "market is not available: foobar", "market": "foobar" } ] } `is_stale` flips true when `collected_at` is older than 30 minutes. `staleness_seconds` is computed server-side against `time.Now()` so the value is consistent for the whole response. ### `GET /v1/spreads` For every item with at least three priced p2p marketplaces, returns the best buy→sell route after fees and a coarse trade-lock risk adjustment. Parameters: - `min_risk_adjusted_spread` (default 0): minimum risk-adjusted net % - `min_listings` (default 1): minimum `listings_count` on both legs - `buy_market`, `sell_market`: CSV marketplace slug filters - `include_outliers` (default false): keep outlier-flagged rows - `limit` (default 50, max 200), `cursor` (opaque offset) Sorted by `risk_adjusted_net_spread_pct` DESC. Response: { "data": [ { "market_hash_name": "AK-47 | Redline (Field-Tested)", "currency": "USD", "buy": { "market": "whitemarket", "market_type": "p2p", "price": "30.20", "buyer_fee_pct": 0, "trade_lock_days": 7, "listings_count": 2438, "confidence": "high" }, "sell": { "market": "skinport", "market_type": "p2p", "price": "32.79", "seller_fee_pct": 12, "listings_count": 546, "confidence": "high" }, "gross_spread_pct": "8.58", "net_spread_pct": "-4.45", "trade_lock_risk_pct": "3.50", "risk_adjusted_net_spread_pct": "-7.95" }, ... ], "next_cursor": "NQ", "risk_model": { "version": "mvp_lock_days_v1", "trade_lock_risk_per_day_pct": "0.50", "coarse": true, "requires_historical_volatility": true } } Spread formula: buy_cost = buy.price × (1 + buyer_fee_pct/100) sell_revenue = sell.price × (1 - seller_fee_pct/100) gross_spread_pct = (sell.price - buy.price) / buy.price × 100 net_spread_pct = (sell_revenue - buy_cost) / buy_cost × 100 trade_lock_risk_pct = buy.trade_lock_days × 0.5 risk_adjusted_net_spread_pct = net_spread_pct - trade_lock_risk_pct Filters applied before sort: 1. Item dropped if fewer than three priced p2p marketplaces are available (consensus floor). 2. Pair dropped if either leg is flagged outlier and `include_outliers=false`. 3. Pair dropped if `risk_adjusted_net_spread_pct > 1000` (sanity cap; placeholder/variant-aliasing artifact). 4. Pair dropped if either leg has `listings_count < min_listings`. Only `p2p` marketplaces are used as legs. Instant-bot prices carry a convenience premium that doesn't translate to arbitrage capacity. --- ## Marketplaces ### `skinport` (p2p, EU) Full ~24,400-item catalog. Their `/v1/items` requires `Accept-Encoding: br` and returns aggregate prices per name. Updated every ~5 min. Skinport holds inventory in their own vault, so there's no Steam trade lock from the buyer's side. ### `csfloat` (p2p) Per-listing data with float values, sticker positions, and pattern indexes. Authenticated API key in `Authorization` header (no `Bearer` prefix on their side). Rate limit is 200 requests per ~30 minutes authenticated — much lower than the 50k/day suggested by their unauthenticated probes. Skintick polls the top-N most-liquid items per cycle rather than the whole catalog. ### `tradeit` (instant_bot, US) Bot inventory of ~930 items via their internal `/api/v2/inventory/data` endpoint. Cloudflare-fronted but lets requests through with browser-like headers + persisted `__cf_bm` cookie. Prices run 75–90% above p2p marketplaces because they're instant-trade asks — Skintick excludes instant_bot rows from spread computation for that reason. ### `dmarket` (p2p) ~1,440 items via cursor pagination. Read endpoints work anonymously. Sales-history endpoint exists but requires an API key Skintick doesn't have yet. ### `lootfarm` (instant_bot) ~2,300 in-stock items via static `fullprice.json` feed. Anonymous, no auth. Similar instant-bot pricing model to Tradeit. ### `whitemarket` (p2p, EU) ~26,300 priced items via static S3 export. Anonymous, no auth, broadest free catalog after Skinport. ~7% seller fee. ### `bitskins` (p2p) ~17,200 pre-aggregated items via anonymous `GET /market/insell/730`. Returns min/avg/max price + quantity per name in a single call. **Known quirk:** `price_min` is sometimes inflated by stickered or StatTrak variants sharing a market_hash_name. Outlier filter catches the worst of it. ### `waxpeer` (p2p, EU/CIS) ~21,300 pre-aggregated items via anonymous `GET /v1/prices?game=csgo`. Returns lowest ask + Steam Market reference price per name. Same variant-aliasing quirk as Bitskins. ### `skinscom` (parked) Client code is in tree but disabled by default. Decodo's mobile proxy blacklisted `api.skins.com` at the CONNECT layer (provider-level policy, not a Cloudflare technical block). Disabled until a working route in exists. --- ## Response field semantics ### `is_outlier` (boolean) True when this marketplace's `min_price` is outside `[median/3, median×3]` across all priced p2p marketplaces for the same item. The 3× band is generous on purpose — it tolerates legitimate instant-bot premiums (50– 90% above p2p) while still catching the >10× artifacts that show up on Bitskins/Waxpeer for items where stickered or StatTrak variants share the canonical name. Requires at least three priced p2p marketplaces in the consensus pool. Below that floor every row gets `is_outlier=false` because the median isn't meaningful with so few inputs. ### `confidence` (string enum) Coarse tier derived from per-row `listings_count`: - `high`: ≥10 listings. The lowest ask is unlikely to swing on a single mispriced seller. - `medium`: 3–9 listings. - `low`: 1–2 listings. Treat with caution — one outlier seller can move the number meaningfully. - `unknown`: marketplace didn't report a count. ### `is_stale` / `staleness_seconds` `staleness_seconds` is `time.Now() - collected_at` evaluated server-side against the request's wall clock. `is_stale` flips true at 30 minutes. Both fields appear on `/v1/prices/latest` rows. ### `market_type` - `p2p`: traditional listing-driven marketplace where sellers post asks individually (Skinport, CSFloat, DMarket, White.market, Bitskins, Waxpeer). - `instant_bot`: marketplace selling from a fixed bot inventory at premium prices for instant delivery (Tradeit, Loot.farm). Skintick uses this to filter spread legs — only `p2p` marketplaces participate in arbitrage calculations. --- ## Open items (not yet shipped) - **Variant-aware normalization** for StatTrak / Souvenir / stickered versions that some marketplaces alias under the same market_hash_name. Outlier flag is a band-aid until this lands. - **Volatility-based trade-lock risk model** to replace the coarse `days × 0.5%` MVP approximation. Requires multi-week price history. - **Sales-history endpoint** for items, pending DMarket and CSFloat API keys with sales-history access. - **Self-service key minting** via dashboard (Clerk integration); CLI today. - **Stripe-driven subscription flow** with webhook → key minting → email delivery. - **OpenAPI examples** — current spec has inline values but not full example payloads per endpoint. --- ## Versioning Path version (`/v1/`) is bumped on breaking response-shape changes. Field additions are non-breaking. The spec's `info.version` follows semver-ish: minor bumps for new fields, patch for non-content changes, major bumps in sync with path version. --- For machine-readable contracts, fetch https://api.skintick.io/openapi.yaml. For real-time per-marketplace health, fetch https://api.skintick.io/v1/status. For repo-level context and conventions, see https://github.com/joelh12/Skintick/blob/main/CLAUDE.md.