Tutorial
How to Build a CS2 Buy Bot with Marketplace WebSockets and the Skinstrack API
Most arbitrage opportunities in CS2 die within seconds of a listing going live. This guide builds a buy bot that subscribes to marketplace WebSockets (Waxpeer, Market.CSGO), validates each new listing against cross-market prices through the Skinstrack API, and decides to buy when the math works.
Read this first
The Skinstrack API does not buy on your behalf. It tells you, in one cached snapshot, what every other marketplace is selling the same item for. The actual buy is executed through the marketplace that pushed the WebSocket event, using that marketplace's own buy endpoint and credentials. Endpoint URLs and payload shapes in this guide match the current Waxpeer and Market.CSGO docs; cross-check against docs.waxpeer.com and market.csgo.com before you ship.
Why WebSockets Plus the Skinstrack API
A buy bot is two pieces glued together: a fast signal that something just listed, and a truth source that says whether the listing is underpriced relative to the rest of the market.
Push beats polling
A buy bot that polls every 60 seconds is already too late. WebSockets push new listings the millisecond they hit the marketplace, which is the only window where genuine mispricings still exist.
Free real-time stream
Waxpeer, Market.CSGO and most major marketplaces expose a public WebSocket for new listings and price updates. You do not pay per event, you just need a process that stays connected.
Cross-market price truth
A new listing is only an opportunity if other marketplaces are selling the same item for more. The Skinstrack API answers that question for every item in one call across 34+ marketplaces.
Liquidity guard built in
The Skinstrack liquidity score prevents you from sniping items that look cheap but will sit in your inventory for months. No score, no buy.
Architecture
Listen to marketplace WS
Open one persistent WebSocket per marketplace (Waxpeer, Market.CSGO, optionally more). Subscribe to new-listing events. Stay connected with auto-reconnect.
Cache cross-market prices
Call getItems({ providers }) once on startup and refresh every 15 minutes. Hold the result in memory as a name to { liquidity, prices } map. WebSocket events read from cache instead of hitting the API per listing.
Score it
Compute net proceeds on the best sell market after fees. If the discount versus your buy price clears your floor (typically 10 to 15 percent) and liquidity is high enough, this is a buy.
Buy, or alert
Either execute through the marketplace buy endpoint (each marketplace has one, each with different auth) or just push the opportunity to Discord and execute manually.
Install the Skinstrack SDK
The official Node SDK wraps the Skinstrack API with TypeScript types and a typed client. Source at github.com/skinstrack/skinstrack-sdk. Python users can call the API directly with requests; the shape is identical.
npm install skinstrack ws centrifugeimport { SkinstracksV1 } from 'skinstrack';
const client = new SkinstracksV1({
apiKey: process.env.SKINSTRACK_KEY,
});
// Sanity check: fetch one item end to end.
const ak = await client.getItem('AK-47 | Redline (Field-Tested)');
const cheapest = ak.prices.reduce((a, b) => (a.price < b.price ? a : b));
console.log(`cheapest ${cheapest.provider} @ $${cheapest.price}`);SDK methods you will actually use
client.getItem(name)- single item with prices across $34+ marketplaces.client.getItems({ providers, avg, median })- bulk with optional aggregates.client.getMarketplaceIds()- cross-marketplace ID mapping for deep links.
Connect to the Waxpeer WebSocket
Waxpeer uses Socket.IO over WebSocket. Auth is a request header at handshake time, not a message: pass authorization: WAXPEER_KEY. After connecting, subscribe to the csgo stream with 42["subscribed",{"name":"csgo"}] and you receive new, update and removed events per item. Prices arrive as integers in 0.001 USD (divide by 1000 to get dollars).
import WebSocket from 'ws';
// Waxpeer Socket.IO endpoint. Auth is a request header at handshake time.
// Docs: https://docs.waxpeer.com (look for SocketIO / Examples).
const WAXPEER_WS = 'wss://waxpeer.com/socket.io/?EIO=4&transport=websocket';
const ws = new WebSocket(WAXPEER_WS, {
headers: { authorization: process.env.WAXPEER_KEY },
});
ws.on('open', () => {
// Subscribe to the public csgo stream. You will receive
// "new", "update" and "removed" events per item.
ws.send('42["subscribed",{"name":"csgo"}]');
console.log('waxpeer connected');
});
ws.on('message', (raw) => {
const text = raw.toString();
if (!text.startsWith('42')) return;
const [eventName, payload] = JSON.parse(text.slice(2));
// Only react to new listings and price updates; ignore "removed".
if (eventName !== 'new' && eventName !== 'update') return;
if (!payload || payload.game !== 'csgo') return;
// payload: { event, game, name, float, price, item_id, paint_index, ... }
// Waxpeer prices are integers in 0.001 USD (e.g. 7260 -> $7.26).
handleListing({
source: 'waxpeer',
name: payload.name,
price: payload.price / 1000,
listingId: String(payload.item_id),
float: payload.float,
});
});Connect to the Market.CSGO WebSocket
Market.CSGO uses Centrifugo, not raw WebSocket, so the flow is two steps. First fetch a short-lived token from GET /api/v2/get-ws-token?key=.... Then connect a Centrifugo client to wss://wsprice.csgo.com/connection/websocket and subscribe to public:items:730:usd (730 is the CS:GO/CS2 Steam appid). The token expires after about 10 minutes, so wire it through the client's getToken callback for automatic refresh.
import { Centrifuge } from 'centrifuge';
import WebSocket from 'ws';
// Market.CSGO uses Centrifugo. Flow:
// 1) GET /api/v2/get-ws-token?key=... -> short-lived token (valid ~10 min)
// 2) Connect to wss://wsprice.csgo.com/connection/websocket with that token
// 3) Subscribe to public:items:730:usd (730 = CS:GO/CS2 appid, currency USD)
// Docs: https://market.csgo.com (Web sockets section).
async function fetchToken() {
const res = await fetch(
`https://market.csgo.com/api/v2/get-ws-token?key=${process.env.MARKETCSGO_KEY}`
);
const data = await res.json();
return data.token;
}
const centrifuge = new Centrifuge('wss://wsprice.csgo.com/connection/websocket', {
websocket: WebSocket,
// Auto-refresh the token; the server rejects it after ~10 minutes.
getToken: fetchToken,
});
const sub = centrifuge.newSubscription('public:items:730:usd');
sub.on('publication', (ctx) => {
// ctx.data is the published item event. The channel publishes both
// new-listing and price-update payloads; field names follow market.csgo
// conventions, so verify against your channel sample.
const d = ctx.data ?? {};
handleListing({
source: 'market.csgo',
name: d.i_market_hash_name ?? d.market_hash_name,
price: parseFloat(d.price ?? d.ui_price),
listingId: String(d.id ?? d.ui_id ?? ''),
});
});
sub.subscribe();
centrifuge.connect();High traffic warning
The public:items:730:usd channel pushes a lot of events. Filter inside your handler before doing anything expensive, keep the per-item cooldown tight, and consider running the listener on a separate process from your decision loop if you ever fall behind.
Cache Cross-Market Prices in Memory
Do not call getItem on every WebSocket event. A busy marketplace pushes dozens of listings per second, and one API call per listing will burn your monthly quota in a day. Instead, pull the whole universe once with getItems, cache it in memory, and refresh every 15 minutes.
Pick the providers you actually want to sell into (Buff163, Youpin and CSFloat are a common starting point because of their low seller fees). The cache becomes your cross-market reference; the WebSocket handler reads from it in microseconds.
// One getItems call covers the whole watchlist for 15 minutes.
// On each WebSocket event we read from memory, not from the API.
const CACHE_PROVIDERS = ['buff', 'youpin', 'csfloat'];
const CACHE_TTL_MS = 15 * 60 * 1000;
let priceCache = new Map(); // name -> { liquidity, prices }
async function refreshCache() {
try {
const items = await client.getItems({
providers: CACHE_PROVIDERS.join(','),
});
const next = new Map();
for (const item of items) {
next.set(item.market_hash_name, {
liquidity: item.liquidity ?? 0,
prices: item.prices,
});
}
priceCache = next;
console.log(`cache refreshed: ${next.size} items`);
} catch (err) {
console.error('cache refresh failed', err);
}
}
// Helper your WebSocket handlers will call.
function getCachedPrices(name) {
return priceCache.get(name);
}
// Warm the cache, then refresh on a timer.
await refreshCache();
setInterval(refreshCache, CACHE_TTL_MS);Why 15 minutes is the right TTL
Buy decisions only need the reference price to be approximately correct. Cross-market prices drift over hours, not seconds. A 15 minute refresh gives you 96 refreshes a day, which fits comfortably inside any paid tier on /api-pricing, while still keeping the cache fresh enough that your fee math holds.
The Buy Decision
For each new listing, read the cached cross-market prices through getCachedPrices(name), compute the best net sell price after fees, and only buy if the discount clears your floor. Gate on liquidity to avoid buying items you cannot exit.
// Per-marketplace seller fees. Verify against current docs.
const SELLER_FEE = {
steam: 0.13, buff: 0.025, csfloat: 0.02, youpin: 0.05,
skinport: 0.12, waxpeer: 0.05, dmarket: 0.05, marketcsgo: 0.05,
};
// Minimum cross-market discount before we are interested.
const MIN_DISCOUNT = 0.12;
const MIN_LIQUIDITY = 50;
function shouldBuy(listing) {
// Read from the 15 minute cache. No API call per event.
const cached = getCachedPrices(listing.name);
if (!cached || cached.liquidity < MIN_LIQUIDITY) return null;
// Where could we sell this for the most, after fees?
const candidates = cached.prices
.filter((p) => p.provider !== listing.source)
.map((p) => ({
provider: p.provider,
gross: p.price,
fee: SELLER_FEE[p.provider] ?? 0.10,
net: p.price * (1 - (SELLER_FEE[p.provider] ?? 0.10)),
}));
if (candidates.length === 0) return null;
const best = candidates.reduce((a, b) => (a.net > b.net ? a : b));
const profit = best.net - listing.price;
const roi = profit / listing.price;
if (roi < MIN_DISCOUNT) return null;
return { listing, sellOn: best, profit, roi, liquidity: cached.liquidity };
}Full Minimal Buy Bot
Around 90 lines, listens to both marketplaces in parallel, deduplicates per item, checks the Skinstrack API, prints buy candidates. Plug your marketplace buy call into executeBuy once you have validated the candidates by hand for a few days.
// minimal-buy-bot.js - run: node minimal-buy-bot.js
import 'dotenv/config';
import WebSocket from 'ws';
import { Centrifuge } from 'centrifuge';
import { SkinstracksV1 } from 'skinstrack';
const client = new SkinstracksV1({ apiKey: process.env.SKINSTRACK_KEY });
const CACHE_PROVIDERS = ['buff', 'youpin', 'csfloat'];
const CACHE_TTL_MS = 15 * 60 * 1000;
const SELLER_FEE = {
steam: 0.13, buff: 0.025, csfloat: 0.02, youpin: 0.05,
skinport: 0.12, waxpeer: 0.05, dmarket: 0.05, marketcsgo: 0.05,
};
const MIN_DISCOUNT = 0.12;
const MIN_LIQUIDITY = 50;
const MAX_BUY_USD = 50; // hard cap per buy
let priceCache = new Map(); // name -> { liquidity, prices }
const cooldown = new Map(); // de-dupe by item slug
async function refreshCache() {
try {
const items = await client.getItems({ providers: CACHE_PROVIDERS.join(',') });
const next = new Map();
for (const item of items) {
next.set(item.market_hash_name, {
liquidity: item.liquidity ?? 0,
prices: item.prices,
});
}
priceCache = next;
console.log(`cache refreshed: ${next.size} items`);
} catch (err) {
console.error('cache refresh failed', err);
}
}
function getCachedPrices(name) {
return priceCache.get(name);
}
function evaluate(listing) {
// Per-item cooldown so a flood of identical listings does not
// re-fire the same decision a hundred times in 200ms.
const last = cooldown.get(listing.name) ?? 0;
if (Date.now() - last < 30_000) return;
cooldown.set(listing.name, Date.now());
if (listing.price > MAX_BUY_USD) return;
// Read from the 15 minute cache. No API call per event.
const cached = getCachedPrices(listing.name);
if (!cached || cached.liquidity < MIN_LIQUIDITY) return;
const candidates = cached.prices
.filter((p) => p.provider !== listing.source)
.map((p) => ({
provider: p.provider,
net: p.price * (1 - (SELLER_FEE[p.provider] ?? 0.10)),
}));
if (!candidates.length) return;
const best = candidates.reduce((a, b) => (a.net > b.net ? a : b));
const roi = (best.net - listing.price) / listing.price;
if (roi < MIN_DISCOUNT) return;
console.log(
`[BUY] ${listing.source} @ $${listing.price} -> sell on ${best.provider}` +
` for ~$${best.net.toFixed(2)} net (${(roi * 100).toFixed(1)}%)`
);
// executeBuy(listing) is your marketplace-specific call;
// each marketplace has its own buy endpoint and signing rules.
}
function connectWaxpeer() {
const ws = new WebSocket(
'wss://waxpeer.com/socket.io/?EIO=4&transport=websocket',
{ headers: { authorization: process.env.WAXPEER_KEY } }
);
ws.on('open', () => ws.send('42["subscribed",{"name":"csgo"}]'));
ws.on('message', (raw) => {
const t = raw.toString();
if (!t.startsWith('42')) return;
const [eventName, payload] = JSON.parse(t.slice(2));
if (eventName !== 'new' && eventName !== 'update') return;
if (!payload || payload.game !== 'csgo') return;
evaluate({
source: 'waxpeer',
name: payload.name,
price: payload.price / 1000,
listingId: String(payload.item_id),
});
});
ws.on('close', () => setTimeout(connectWaxpeer, 2000));
}
function connectMarketCsgo() {
const centrifuge = new Centrifuge(
'wss://wsprice.csgo.com/connection/websocket',
{
websocket: WebSocket,
getToken: async () => {
const res = await fetch(
`https://market.csgo.com/api/v2/get-ws-token?key=${process.env.MARKETCSGO_KEY}`
);
const data = await res.json();
return data.token;
},
}
);
const sub = centrifuge.newSubscription('public:items:730:usd');
sub.on('publication', (ctx) => {
const d = ctx.data ?? {};
evaluate({
source: 'marketcsgo',
name: d.i_market_hash_name ?? d.market_hash_name,
price: parseFloat(d.price ?? d.ui_price),
listingId: String(d.id ?? d.ui_id ?? ''),
});
});
sub.subscribe();
centrifuge.connect();
}
// Warm the cache before listening to events.
await refreshCache();
setInterval(refreshCache, CACHE_TTL_MS);
connectWaxpeer();
connectMarketCsgo();Sanity-check before going live
Open the Skinstrack comparison tool while the bot runs in print-only mode. Every candidate the bot logs should also show up as a cross-market opportunity in the UI. If it does not, your fee table or liquidity floor is probably wrong.
Risks Your Bot Will Not Catch
Race conditions are real
A profitable new listing visible on a public WebSocket is visible to every other bot too. Expect to lose most of them. Bots win by being faster, by watching more sources, and by having more capital sitting on the right marketplaces ready to deploy.
Trade locks distort returns
Steam-routed purchases lock the item for up to 7 days. Buying a 12 percent discount you cannot realize for a week is not the same trade as one you can flip in an hour.
Marketplace fees and bonuses change
Keep your SELLER_FEE table outside the code. Some marketplaces add temporary fee bonuses, kickbacks or loyalty discounts that can change the math materially.
Float and pattern mismatch
A new listing on Waxpeer with a 0.45 float is not the same item as the Buff163 listing at 0.18, even if the market hash name matches. For high-value buys, verify the actual specifics before pulling the trigger.
Stale cache risk
A 15 minute TTL keeps the bot quota-friendly but means your cross-market reference price can drift between refreshes. For high-value or fast-moving items, shorten the TTL, restrict the watchlist to a smaller market_hash_names filter, or fall back to a single getItem call on borderline candidates.
Get the API key your buy bot needs
Growth tier covers a busy WebSocket workload across multiple marketplaces for a full month.
Frequently Asked Questions
Do Waxpeer and Market.CSGO have public WebSockets?
Yes. Waxpeer is Socket.IO at wss://waxpeer.com/socket.io/?EIO=4&transport=websocket. Authenticate with an authorization header containing your Waxpeer API key, then send 42["subscribed",{"name":"csgo"}] to receive new, update and removed events per item. Market.CSGO uses Centrifugo at wss://wsprice.csgo.com/connection/websocket, with a short-lived token fetched from /api/v2/get-ws-token; subscribe to public:items:730:usd for CS2 listings in USD. Skinstrack is what you call to get cross-market truth on each event.
Does the Skinstrack API execute the buy for me?
No. Skinstrack provides cross-marketplace prices, volume, liquidity and item metadata. The actual buy is executed through the marketplace where the listing appeared, using that marketplace's own buy endpoint and credentials. The bot in this guide identifies and scores the opportunity, you or your execution code complete it.
How do I install the Skinstrack SDK?
Run npm install skinstrack. Import { SkinstracksV1 } from skinstrack, instantiate it with your API key from /api-pricing, and call methods like getItem, getItems or getMarketplaceIds. The SDK is open source at github.com/skinstrack/skinstrack-sdk.
What is the difference between a buy bot and an arbitrage bot?
An arbitrage bot polls the full snapshot every few minutes and surfaces persistent spreads. A buy bot reacts the instant a new listing hits a marketplace, before the price corrects. Buy bots are faster, more competitive, and require lower fees and a higher liquidity bar. Both rely on Skinstrack for cross-market price truth.
Which marketplaces should I listen to first?
Waxpeer and Market.CSGO are common starting points because their WebSocket APIs are well documented and their listing flow is fast. CSFloat and Skinport are also worth watching once your bot is stable. The Skinstrack API normalizes 27 marketplaces in the response either way, so the harder problem is execution, not data.
Will I actually make money running this?
Sometimes. The same WebSocket you are listening to is being listened to by other bots, and the easy opportunities go fast. Expect to lose more races than you win. The bots that net out positive are the ones with tight fee math, strict liquidity floors, fast execution paths, and capital pre-staged on the right marketplaces. Treat your first month as calibration, not as profit.
Related Guides
CS2 Arbitrage Bot Guide
Slower-cycle counterpart. Polls the full snapshot and surfaces persistent spreads.
CS2 Skin Price API Guide
The foundation. Endpoints, response shape and rate limit guidance.
CS2 Market Data API
Volume, liquidity and 7d to 90d aggregates for analytics and backtesting.
