GGScore API Rate Limits, Caching, and Free Tier: A Production Guide
6/25/2026, 12:37:12 PM
Hit 429 on launch day? Free tier is 3 req/day—enough to prototype, not to poll from the browser. Cache server-side, budget requests, upgrade when logs say so.
GGScore API Rate Limits, Caching, and Free Tier: A Production Guide
Your CS2 tracker works in dev. You ship to ten users. On day one the API returns 429 Too Many Requests and your dashboard goes blank. The bug is rarely "bad code" — it is uncached client-side polling against a daily quota you never budgeted.
GGScore is a paid CS2 match data API with clear tiers: Free for prototyping, Premium and Black for real traffic. This guide shows how to count requests, cache server-side, and handle 429 so Free stays useful and production stays stable. Verify current limits on ggscore.net pricing before you ship.
What You Are Actually Limited On
Public tiers (check live page for exact numbers):
| Tier | Orient. quota | Typical use |
|---|---|---|
| Free | 3 requests / day | Key test, one cron, local integration |
| Premium | 100 requests / day | Small bot or widget with cache |
| Black | Unlimited positioning | Production scale, priority support |
Free also allows multiple API keys (e.g. 5 keys on the public site) — useful for dev/staging separation, not for multiplying the same daily quota across keys in one app.
Important: limits are usually per account / key policy, not "unlimited if I open five tabs." Design as if the whole product shares one budget until docs say otherwise.
Why 3 Requests Per Day Is Enough (and Not Enough)
Enough for:
- One
played-matchescall to inspect JSON shape - One
upcoming-matchescall to map schedule fields - One spare call after you fix a mapping bug
Not enough for:
- Calling the API on every page view from the browser
- A Discord bot polling every 30 seconds
- Health checks that hit GGScore every minute
- Retries without backoff burning the daily cap in minutes
Free is a contract test, not hosting for users.
Request Budget: Count Before You Code
List every caller that touches GGScore:
| Source | Endpoint | Calls per user action | Users / hour | Uncached daily risk |
|---|---|---|---|---|
| Web homepage | played + upcoming | 2 | 500 visitors | 1000+ |
Discord /recent | played | 1 | 200 commands | 200+ |
| Cron refresh | both | 2 | 24 runs | 48 |
With 3/day on Free, even a single uncached homepage widget exceeds quota before lunch.
Rule: every production path goes through your backend + cache. The browser never holds the API key; it never calls GGScore directly.
Server-Side Cache: Non-Negotiable
Pattern
[Browser / Bot / Mobile]
↓
[Your API route] ← reads cache first
↓
[GGScore] ← only on cache miss (or TTL expiry)
Suggested TTLs (starting points)
| Data | Endpoint family | TTL | Why |
|---|---|---|---|
| Recent results | played-matches | 60–120 s | Scores change slowly after match end |
| Today's schedule | upcoming-matches | 5–15 min | Fixtures shift less often than live scores |
| Countries / static refs | countries (per docs) | 24 h | Rarely changes |
Tune TTLs against your tier: on Premium (100/day), a 10-minute upcoming cache = max ~144 calls/day per endpoint if you poll naively — still too many. Cache per cache key, not per visitor.
Single-flight (dedupe thundering herd)
When cache expires, 50 simultaneous users should not trigger 50 upstream calls:
const inflight = new Map();
async function getPlayedCached(redis, fetchPlayed) {
const key = 'ggscore:played:limit10';
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
if (!inflight.has(key)) {
inflight.set(key, fetchPlayed().finally(() => inflight.delete(key)));
}
const data = await inflight.get(key);
await redis.setex(key, 90, JSON.stringify(data));
return data;
}
Same idea in Python with asyncio.Lock or a short-lived "loading" flag in Redis.
Stale-while-revalidate (optional)
On 429 or upstream timeout, serve stale cache with a stale: true flag in your API response instead of an empty UI. Log the incident; alert if stale age > 30 minutes during a tournament day.
Handling 401 and 429
| Status | Meaning | Action |
|---|---|---|
| 401 | Missing / invalid X-API-Key | Fix env, rotate leaked key, never expose in frontend |
| 429 | Quota exceeded | Back off; do not retry in a tight loop |
Backoff sketch (Node)
async function fetchWithBackoff(url, options, maxAttempts = 4) {
let delayMs = 1000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, options);
if (res.status !== 429) return res;
if (attempt === maxAttempts) throw new Error('Rate limited after retries');
await new Promise((r) => setTimeout(r, delayMs));
delayMs *= 2;
}
}
On Free, treat the first 429 of the day as "stop until tomorrow" for automated jobs — do not burn retries that will never succeed.
User-facing copy: "Match data temporarily unavailable — cached results shown" beats a silent failure.
Example: One Day on Free vs Premium
Scenario: landing page shows last 5 played + next 5 upcoming.
| Strategy | GGScore calls / visitor | 100 visitors/day |
|---|---|---|
| No cache, client-side | 2 | 200 |
| Server cache 10 min | 2 / 1440 min ≈ 0.003 avg | ~0.3 effective bursts per 10 min window |
| Server cache + single-flight | 2 per TTL window | 2–4 per endpoint per day |
Free (3/day): only the cached server pattern works for a public page — and only if traffic is low. Real launch → Premium or Black.
Discord and Cron: Poll Smarter
From the Discord bot guide:
- One subcommand = one cached backend route, not one live GGScore call per click.
- Hash last payload; skip Discord message edits when nothing changed.
- Cron every 5–15 minutes for upcoming, not every 30 seconds.
If you need both played and upcoming, see played + upcoming fundamentals for endpoint split — two cache keys, two TTLs, one shared daily budget. (Fix slug to cs2-played-upcoming-matches-api-guide in admin when editing that post.)
When to Upgrade
| Signal | Likely tier |
|---|---|
| Solo dev, local tests only | Free |
| Small server / blog widget, cache in place, <100 upstream calls/day measured | Premium |
| Public product, many users, tight freshness, or burst traffic | Black |
Upgrade when measured daily upstream calls (from logs) exceed ~80% of quota for three days — not on day one optimism.
Logging and Alerts (Minimal)
Log per upstream call:
- endpoint, status, latency, cache hit/miss
remainingheader if docs expose it (do not assume)
Alert when:
- 429 rate > 0 in production
- cache miss storm (>10 upstream calls in 1 minute)
- stale responses served > 15 minutes during an event you advertise as "live"
Security Reminder
- API key in server env only (
GGSCORE_API_KEY) - Rotate on leak; separate keys for staging if policy allows
- Never commit keys to Git or embed in client bundles
Checklist Before Go-Live
- Request budget table written (who calls what, how often)
- All user traffic hits your API, not GGScore directly
- TTLs set per endpoint (played shorter than upcoming)
- Single-flight or lock on cache miss
- 429 backoff + user-visible fallback (stale cache OK)
- Daily upstream call count measured in staging
- Tier matches budget (Free ≠ production)
- Pricing page re-checked for current req/day numbers
CTA
- Get an API key at ggscore.net — validate on Free, measure, then upgrade.
- Read the docs for exact auth headers, endpoints, and response schemas.
- Build cache first, features second — your future self at 429 o'clock will thank you.
Bottom Line
Rate limits are a design input, not a surprise tax. GGScore Free proves the integration; caching and the right tier make it a product. Count requests, cache on the server, back off on 429, upgrade when logs say so.
What is your current cache TTL for upcoming matches — still polling every page load?