How to build a CS2 price alert bot for Discord
Build a Discord bot that alerts on CS2 price drops and spread changes, with batched polling, rate-limit headers, and 429 backoff. Full runnable code.
On this page
A price alert bot is the classic first project on top of a market data API: keep a watchlist, check prices on a schedule, and post to a Discord channel when something crosses your line. The hard part is not the Discord message. It is polling efficiently enough that the bot runs all month on your plan, and behaving correctly when a quote is stale or the API asks you to slow down.
This guide builds a complete bot in about 80 lines of TypeScript. It checks buy-side alerts (the lowest ask across every marketplace dropped to your target) with one batched request per poll, checks sell-side alerts (someone is bidding at or above your target) from the cross-market best bid, reads the rate-limit headers, and backs off on 429. It posts to a Discord webhook that you create in your own server.
How the bot is put together#
| Piece | What it does |
|---|---|
| Watchlist | Item names plus a target price in integer USD cents, for example 3000 for $30.00. |
GET /v1/aggregations/min-prices | The lowest ask on every market for up to 100 items in one request. This is the buy-side check. |
GET /v1/nbbo | The best bid and ask across all venues for one item. This is the sell-side check. |
| Rate-limit headers | X-RateLimit-Remaining and friends on every response, so the bot can see its budget. See Rate Limits. |
| Discord webhook | A URL from your Discord server that accepts a JSON POST. SkinPricer does not push to Discord; your bot does. |
Create the webhook in Discord under the channel's settings (Integrations, then Webhooks) and copy its URL. Treat it like a password: anyone holding it can post to your channel. Keep it and your SkinPricer key in environment variables, never in the source.
Step 1: check a whole watchlist in one request#
Calling the NBBO once per item works for three items and falls apart at thirty. The min-prices aggregation takes up to 100 market hash names (or canonical ids) as a comma-separated list and returns each item's lowest ask on every market. It is included from the Developer plan up, and the response is snake_case. Try it with curl first; the -i flag prints the response headers so you can see your remaining budget.
export SKINPRICER_API_KEY="sp_live_•••" # from your account's API keys page
curl -s -i -G "https://pricing.skinpricer.com/v1/aggregations/min-prices" \
--data-urlencode "marketHashNames=AK-47 | Redline (Field-Tested),AWP | Asiimov (Field-Tested)" \
-H "Authorization: ApiKey $SKINPRICER_API_KEY"import os
import requests
names = ["AK-47 | Redline (Field-Tested)", "AWP | Asiimov (Field-Tested)"]
res = requests.get(
"https://pricing.skinpricer.com/v1/aggregations/min-prices",
params={"marketHashNames": ",".join(names)},
headers={"Authorization": f"ApiKey {os.environ['SKINPRICER_API_KEY']}"},
timeout=10,
)
res.raise_for_status()
print("requests left this minute:", res.headers.get("X-RateLimit-Remaining"))
for item in res.json()["items"]:
fresh = [p for p in item["prices"] if not p.get("is_stale")]
if fresh:
best = min(fresh, key=lambda p: p["price"])
print(item["market_hash_name"], best["market_name"], best["price"] / 100){
"items": [
{
"canonical_item_id": "cmlofca920lka01yozajhixt3",
"market_hash_name": "Glock-18 | Water Elemental (Factory New)",
"prices": [
{
"market_name": "csfloat",
"offers": 132,
"price": 6500,
"updated_at": "2026-06-23T21:52:27.427Z",
"is_stale": false,
"ingested_at": "2026-06-23T21:55:56.923Z",
"ingest_latency_ms": 209496
},
{
"market_name": "skinport",
"offers": 40,
"price": 6516,
"updated_at": "2026-06-23T21:51:53.531Z",
"is_stale": false,
"ingested_at": "2026-06-23T21:54:30.775Z",
"ingest_latency_ms": 157244
}
]
}
]
}offers is the quote size on that market, and is_stale marks a quote past its freshness window.Two details matter for alerts. First, items the API cannot resolve are dropped silently rather than returned as errors, so compare the names you sent with the names you got back if you want to catch typos. Second, a stale quote is still returned as a fallback, which is useful for display but wrong for an alert. The bot below ignores stale prices when it looks for the cheapest market.
Step 2: the complete bot#
The bot polls on a fixed interval, builds one message per poll, and posts it only when there is something to say. It remembers the last price it alerted on for each item, so a price sitting under your target does not trigger a message every poll. The official SDK handles authentication, retries, and header parsing.
// npm i @skinpricer/sdk
import { SkinpricerClient, RateLimitError, formatUsd, getMeta } from "@skinpricer/sdk";
const client = new SkinpricerClient({
apiKey: process.env.SKINPRICER_API_KEY!,
authScheme: "ApiKey",
autoThrottle: true, // wait for the window to reset instead of firing a doomed request
});
const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL!;
const POLL_MS = 5 * 60_000; // one batched call every 5 min fits a 10,000/month quota
interface Watch {
name: string;
buyBelowCents?: number; // alert when the lowest fresh ask drops to this
sellAboveCents?: number; // alert when the best bid reaches this
}
const WATCHLIST: Watch[] = [
{ name: "AK-47 | Redline (Field-Tested)", buyBelowCents: 3000 },
{ name: "AWP | Asiimov (Field-Tested)", sellAboveCents: 12000 },
];
const lastAlert = new Map<string, number>();
function shouldAlert(key: string, priceCents: number): boolean {
if (lastAlert.get(key) === priceCents) return false;
lastAlert.set(key, priceCents);
return true;
}
async function buySideAlerts(): Promise<string[]> {
const watched = WATCHLIST.filter((w) => w.buyBelowCents !== undefined);
if (watched.length === 0) return [];
const res = await client.aggregations.minPrices({
marketHashNames: watched.map((w) => w.name),
});
console.log("budget left this minute:", getMeta(res)?.rateLimit.remaining);
const lines: string[] = [];
for (const item of res.items) {
const watch = watched.find((w) => w.name === item.market_hash_name);
const fresh = item.prices.filter((p) => !p.is_stale);
if (!watch?.buyBelowCents || fresh.length === 0) continue;
const best = fresh.reduce((a, b) => (b.price < a.price ? b : a));
if (best.price <= watch.buyBelowCents && shouldAlert(`buy:${watch.name}`, best.price)) {
lines.push(`BUY **${watch.name}** is ${formatUsd(best.price)} on ${best.market_name}`);
}
}
return lines;
}
async function sellSideAlerts(): Promise<string[]> {
const lines: string[] = [];
for (const watch of WATCHLIST) {
if (watch.sellAboveCents === undefined) continue;
const quote = await client.nbbo.get({ marketHashName: watch.name });
const bid = quote.bestBid;
if (!bid || bid.isStale || quote.confidence < 0.5) continue;
if (bid.price >= watch.sellAboveCents && shouldAlert(`sell:${watch.name}`, bid.price)) {
lines.push(`SELL **${watch.name}** has a ${formatUsd(bid.price)} bid on ${bid.market}`);
}
}
return lines;
}
async function postToDiscord(lines: string[]): Promise<void> {
if (lines.length === 0) return;
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
// Discord caps a message at 2000 characters.
body: JSON.stringify({ content: lines.join("\n").slice(0, 2000) }),
});
if (!res.ok) console.error(`Discord webhook returned ${res.status}`);
}
async function main(): Promise<void> {
for (;;) {
let waitMs = POLL_MS;
try {
const lines = [...(await buySideAlerts()), ...(await sellSideAlerts())];
await postToDiscord(lines);
} catch (err) {
if (err instanceof RateLimitError) {
// The SDK already retried; honor Retry-After before the next poll.
waitMs = Math.max(waitMs, (err.retryAfterSeconds ?? 60) * 1000);
} else {
console.error(err);
}
}
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
void main();The sell-side check reads this: the current NBBO for AK-47 | Redline (Field-Tested), fetched live.
Step 3: size the polling interval to your plan#
Your plan has a per-minute limit and, below Enterprise, a monthly quota, and both are pooled across every API key on the account. Creating a second key for the bot does not add budget. The monthly quota is usually the one that bites: a request every minute is about 43,200 requests in a 30-day month, well over the Developer plan's 10,000.
| Plan | Monthly quota | Batched poll interval that fits |
|---|---|---|
| Developer | 10,000 requests | Every 5 minutes (about 8,640 a month) |
| Pro | 200,000 requests | Every 15 seconds (about 172,800 a month) |
| Enterprise | No monthly cap | Bound by the 3,000 per minute limit |
Remember to add the sell-side NBBO calls to that math, since each one is a separate request. Every key-gated response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset for the minute window, plus -Month variants on plans with a monthly cap. The SDK exposes them through getMeta(response).rateLimit, which is how the bot logs its remaining budget.
Make the alerts trustworthy#
- Skip stale quotes. Every quote carries a staleness flag (
is_stalehere,isStaleon the NBBO) for prices older than the 30-minute freshness window. - Gate sell alerts on the NBBO's
confidenceand a freshbestBid. A lone stale bid can look like a windfall. - Treat
nullas unknown, never as zero. An item with no bid returnsbestBid: null, and the bot simply skips it. - Remember that raw prices exclude fees. For alerts that mean "I would actually net this much", read the NBBO's
executionAdjustedblock, covered in the best bid and ask guide. - Handle
401and403as configuration errors, not transient ones: the key is wrong or the plan lacks the endpoint. The Errors & status codes page lists every status.
When to reach for a different endpoint#
For a watchlist of more than 100 items, split it into chunks of 100 and call min-prices once per chunk, or use the SDK's minPricesAll helper with canonical ids. If you care about the depth behind a price rather than the top quote, pair alerts with order-book depth. To alert on your whole inventory's value instead of single items, see how to value a CS2 inventory. Before trusting any of it, the data freshness guide shows how to check that a market is actually updating.
Endpoints used in this guide
Plans start at $50/month on the Developer tier. Want to try first? Ask in our Discord for a free 2-day Developer key.
Related guides
- Prices & quotesBeginner
How to find the best bid and ask for a CS2 skin across every market
Find the highest buy order and lowest ask for any CS2 skin across every marketplace at once, with freshness, spread, and confidence. Runnable code included.
GET /v1/nbbo6 min read - Build with the APIIntermediate
How to value a CS2 inventory at live market prices
Value a CS2 inventory at live market prices: batch lowest asks and highest buy orders, add liquidity, and report a realistic liquidation value.
GET /v1/aggregations/min-pricesGET /v1/aggregations/max-orders6 min read - Catalog & data qualityBeginner
How to check CS2 market data freshness before you trust a price
Check CS2 market data freshness before you trust a price: per-market ingest status, latency percentiles, stale flags, and marketplace health.
GET /v1/status/freshnessGET /v1/status/market-latency7 min read