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

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

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`](/api-docs/aggregations) | The lowest ask on every market for up to 100 items in one request. This is the buy-side check. |
| [`GET /v1/nbbo`](/api-docs/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](/api-docs/rate). |
| 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.

**curl**

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

**Python**

```python
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)
```

*Item names contain spaces and pipes, so let curl or requests URL-encode them.*

**Response 200** (`GET /v1/aggregations/min-prices`)

```json
{
  "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
        }
      ]
    }
  ]
}
```

*Each item lists its lowest ask per market. `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.

```ts
// 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();
```

*Run it with DISCORD_WEBHOOK_URL and SKINPRICER_API_KEY set. Each poll costs one request plus one per sell-side item.*

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

> **Do not retry a 429 in a tight loop:** When either window is exhausted the API returns `429 Too Many Requests` with a `Retry-After` header in seconds. The SDK retries GETs twice with backoff and honors `Retry-After`; if it still fails you get a typed `RateLimitError` with `retryAfterSeconds`. Wait at least that long. Hammering a limited key only extends the outage.

## Make the alerts trustworthy

- Skip stale quotes. Every quote carries a staleness flag (`is_stale` here, `isStale` on the NBBO) for prices older than the 30-minute freshness window.
- Gate sell alerts on the NBBO's `confidence` and a fresh `bestBid`. A lone stale bid can look like a windfall.
- Treat `null` as unknown, never as zero. An item with no bid returns `bestBid: 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 `executionAdjusted` block, covered in the [best bid and ask guide](/resources/cs2-best-bid-and-ask).
- Handle `401` and `403` as configuration errors, not transient ones: the key is wrong or the plan lacks the endpoint. The [Errors & status codes](/api-docs/errors-status) page lists every status.

> **Keep Discord happy too:** Discord rate-limits webhooks as well, which is why the bot joins every alert from one poll into a single message instead of posting one message per item.

## 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](/api-docs/depth). To alert on your whole inventory's value instead of single items, see [how to value a CS2 inventory](/resources/cs2-inventory-value). Before trusting any of it, the [data freshness guide](/resources/cs2-market-data-freshness) shows how to check that a market is actually updating.

## Endpoints used

- [GET /v1/aggregations/min-prices](/api-docs/aggregations.md)
- [GET /v1/nbbo](/api-docs/nbbo.md)
- [Rate Limits](/api-docs/rate.md)
- [Errors & status codes](/api-docs/errors-status.md)

## Related guides

- [How to find the best bid and ask for a CS2 skin across every market](/resources/cs2-best-bid-and-ask.md)
- [How to value a CS2 inventory at live market prices](/resources/cs2-inventory-value.md)
- [How to check CS2 market data freshness before you trust a price](/resources/cs2-market-data-freshness.md)
