DEV Community

Cover image for From Free Tier to Production: Migrating Your App Through Orbistats' Pricing Tiers
orbistats
orbistats

Posted on

From Free Tier to Production: Migrating Your App Through Orbistats' Pricing Tiers

Disclosure: I work with the Orbistats team. This guide is written to be useful whichever plan you end up on, including staying on the free one.

Every successful API-powered app goes through the same awkward phase. It starts as a weekend prototype on a free key, works beautifully with three test users, and then someone says the dangerous sentence: "Can we launch this next month?"

Suddenly you have real questions. Will the free tier survive real traffic? When exactly do you need to pay? What changes in your code when you move from polling to real time? Do you need historical data now or later? And how do you upgrade without a rewrite or a Friday-night outage?

This post walks through that journey step by step using Orbistats, which covers 13 sports (football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing) behind one API. We'll cover:

What each pricing tier is actually for
How to make the free tier last far longer than you think
A request budget tracker you can copy
A cost estimator so you can predict usage before launch
How to move from polling to WebSockets and webhooks
How to backfill historical data safely
A phase-by-phase migration plan with go-live checklists

All code is plain Node.js 18 or newer, so you can drop it into any backend.

The four tiers at a glance

At the time of writing (October 2026), the pricing page lists four tiers. Always confirm current limits there before you plan around them.

Free is $0 and positioned for testing. The documentation describes 150 requests per day on this plan, which is the number that shapes everything in the first half of this article.

Starter is $19 per month and positioned for real-time production use.

Growth is $79 per month and adds historical data, widgets and webhooks.

Enterprise is custom pricing with dedicated infrastructure, for teams with specific volume, support or delivery requirements.

A useful way to read this ladder is that each step unlocks a new capability, not just a bigger number. Free lets you prove the idea. Starter lets you run it live. Growth lets you build a richer product around history, embeds and push delivery. Enterprise lets you run it at serious scale with infrastructure of your own.

Stage 1: Prototype on the free tier

Start here, and don't feel bad about it. A free tier exists so you can find out whether your idea deserves money.

First, create an account and copy your API key. Second, read the quickstart and the documentation. Authentication is a bearer token, and the base URL is:

text
https://api.orbistats.com/v1/

Third, before you write any code, try a few calls in the Sandbox to see what real responses look like. Ten minutes there will save you hours of guessing field names.

Your first request looks like this:

js
// first-call.js
const res = await fetch("https://api.orbistats.com/v1/football/fixtures", {
headers: {
Authorization: Bearer ${process.env.ORBISTATS_API_KEY},
Accept: "application/json",
},
});

console.log(res.status);
console.log(await res.json());

Run it with node first-call.js and a key in your environment. If you see a 200 and some JSON, you are in business.

The important mindset for stage one: treat the daily limit as a design constraint from day one, not as a surprise at launch. Which brings us to the most valuable habit in this whole article.

Why 150 requests per day is enough for a prototype (if you are smart)

150 sounds tiny, but notice what it actually limits. It limits requests from your server to Orbistats. It does not limit how many people use your app.

If ten thousand users open your football page and your server answers all of them from a cache, you make one upstream request, not ten thousand. That single idea is the difference between a prototype that dies at ten users and one that survives a demo day.

The rule: users talk to your cache, and your cache talks to Orbistats.

Different data deserves different cache lifetimes, because different data changes at different speeds:

Live scores change constantly, so they get short lifetimes.
Fixtures change a few times a day.
Standings change after matches finish.
Team and competition lists barely change at all.

Build the client: cache, de-duplication, stale fallback and retries

Here is a small client that does the four things every production integration needs, even on the free plan. It caches by data type, shares one in-flight request between simultaneous callers, serves slightly old data if the API fails, and retries politely.

First, a tier configuration, so your code knows what the current plan allows:

js
// tiers.js
// null means: take the value from the Orbistats docs and set it in your environment.
export const TIERS = {
free: { dailyBudget: 150, realtime: false, historical: false, webhooks: false, widgets: false },
starter: { dailyBudget: null, realtime: true, historical: false, webhooks: false, widgets: false },
growth: { dailyBudget: null, realtime: true, historical: true, webhooks: true, widgets: true },
enterprise: { dailyBudget: null, realtime: true, historical: true, webhooks: true, widgets: true },
};

export const tier = TIERS[process.env.ORBISTATS_TIER ?? "free"];

Notice that the plan is an environment variable. When you upgrade, you change one setting and the rest of your app adapts, which is exactly the migration experience you want.

Next, a request budget tracker. It counts every upstream call and refuses to exceed your plan, so a bug can never burn your whole day's allowance in ten minutes:

js
// budget.js
import { tier } from "./tiers.js";

const today = () => new Date().toISOString().slice(0, 10);

export const budget = {
limit: Number(process.env.ORBISTATS_DAILY_BUDGET) || tier.dailyBudget,
day: today(),
used: 0,

take() {
// Roll the counter over at the start of a new day.
// This assumes a UTC day. Confirm how the reset window works in the docs.
if (this.day !== today()) {
this.day = today();
this.used = 0;
}
if (this.limit && this.used >= this.limit) return false;
this.used += 1;
return true;
},

status() {
return {
used: this.used,
limit: this.limit,
ratio: this.limit ? this.used / this.limit : null,
};
},
};

Now the client itself:

js
// client.js
import { budget } from "./budget.js";

const BASE = "https://api.orbistats.com/v1";
const cache = new Map();
const inflight = new Map();

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function fetchWithRetry(path, attempts = 3) {
for (let attempt = 0; attempt < attempts; attempt++) {
if (!budget.take()) {
throw Object.assign(new Error("Daily request budget used up"), { status: 429 });
}

const res = await fetch(`${BASE}${path}`, {
  headers: {
    Authorization: `Bearer ${process.env.ORBISTATS_API_KEY}`,
    Accept: "application/json",
  },
});

if (res.ok) return res.json();

const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt === attempts - 1) {
  throw Object.assign(new Error(`Orbistats ${res.status} on ${path}`), {
    status: res.status,
  });
}

// Exponential backoff: 500ms, 1000ms, 2000ms.
await sleep(500 << attempt);
Enter fullscreen mode Exit fullscreen mode

}
}

export async function getData(path, { ttlMs = 30_000, staleMs = 600_000 } = {}) {
const now = Date.now();
const hit = cache.get(path);

// 1. Fresh cache wins.
if (hit && now - hit.at < ttlMs) return { data: hit.data, source: "cache" };

// 2. If someone is already fetching this path, wait for that request.
if (inflight.has(path)) return inflight.get(path);

// 3. Otherwise fetch, and fall back to slightly old data if it fails.
const job = fetchWithRetry(path)
.then((data) => {
cache.set(path, { at: Date.now(), data });
return { data, source: "live" };
})
.catch((err) => {
if (hit && now - hit.at < staleMs) return { data: hit.data, source: "stale" };
throw err;
})
.finally(() => inflight.delete(path));

inflight.set(path, job);
return job;
}

Why each piece matters:

The in-flight map stops a "thundering herd". When 500 users load a page at the same instant, they share one upstream call.

Stale fallback means that if Orbistats or your network has a brief problem, users see data that is a few minutes old instead of an error page. For standings or fixtures, that is almost always the right trade-off.

Backoff only retries errors that can recover (429 and 5xx). A 401 or 404 won't fix itself, so you fail fast. In production you should also respect a Retry-After header if the API sends one, and add a little random jitter to your delays.

The budget guard turns a silent quota problem into a clear, loggable error.

Using it in a route:

js
// server.js (excerpt)
import "dotenv/config";
import express from "express";
import { getData } from "./client.js";
import { budget } from "./budget.js";

const app = express();

app.get("/api/:sport/live", async (req, res, next) => {
try {
const { data, source } = await getData(/${req.params.sport}/matches/live, {
ttlMs: 600_000, // free-tier friendly: 10 minutes
});
res.set("X-Data-Source", source);
res.json(data);
} catch (e) { next(e); }
});

app.get("/internal/usage", (_req, res) => res.json(budget.status()));

app.listen(3000);

The X-Data-Source header and the /internal/usage endpoint cost nothing and give you instant visibility. Check them while you develop, so you always know whether a response came from cache, from the live API, or from the stale fallback.

Predict your usage before you launch

Guessing is how people get surprised. Because your cache decouples users from requests, you can estimate upstream usage with simple arithmetic. It depends on how many sports you show and how fresh each feed needs to be, not on how many visitors you have.

js
// estimate.js
// Upstream requests per day = for each feed, active seconds divided by cache lifetime.
export function dailyRequests(plan) {
return plan.reduce(
(sum, feed) => sum + Math.floor(feed.activeSeconds / feed.ttlSeconds),
0
);
}

const eagerPlan = [
{ name: "football live", ttlSeconds: 15, activeSeconds: 28_800 }, // 8 hours
{ name: "football fixtures", ttlSeconds: 300, activeSeconds: 86_400 },
{ name: "football standings", ttlSeconds: 600, activeSeconds: 86_400 },
];

const frugalPlan = [
{ name: "football live", ttlSeconds: 600, activeSeconds: 28_800 },
{ name: "football fixtures", ttlSeconds: 3_600, activeSeconds: 86_400 },
{ name: "football standings", ttlSeconds: 21_600, activeSeconds: 86_400 },
];

console.log("eager:", dailyRequests(eagerPlan)); // 2352 per day
console.log("frugal:", dailyRequests(frugalPlan)); // 76 per day

Run it and look at the gap. A single sport with 15-second live caching needs 2,352 requests a day, which is far beyond the free plan. The same sport with relaxed cache times needs 76. That is the real shape of the decision:

If your product is fine with live data that is several minutes old, the free tier can carry a surprising amount.

If your product promises genuinely live scores, you have outgrown the free tier, and that's exactly what the next plan is for.

Multiply the exercise by the number of sports you cover, and you'll know in five minutes which plan you need.

Signals that it's time to upgrade

You don't need a spreadsheet to know when to move. Upgrade when you see these:

Your budget tracker regularly passes 80 percent before the day is half over.
Product asked for live scores that update in seconds, not minutes.
You are caching so aggressively that the data feels stale to your users.
You are covering more sports, and the multiplication no longer fits.
You need history for charts, head-to-head or models.
You need push delivery instead of polling.
You are about to run a campaign, a launch or a tournament that will spike traffic.
You've started charging users or promising an uptime level.

Here is a simple, honest way to think about cost. The Starter tier is $19 per month. If staying on the free plan forces even an hour of extra engineering time per month to squeeze usage, the paid plan is probably the cheaper option. Developer time is the expensive resource, so spend money to save it.

Stage 2: Go live on Starter

Starter is the real-time production step. The big change in your architecture is that you stop polling and start listening.

Polling every 15 seconds is wasteful. Most requests return "nothing changed". A WebSocket keeps one connection open, and updates arrive the moment they happen. Pair that with the Live Scores API for the initial state and you have a proper live experience.

Here is the pattern: load the current state over REST once, then keep it fresh from the stream.

js
// realtime.js
import "dotenv/config";
import WebSocket from "ws"; // npm install ws
import { tier } from "./tiers.js";

const listeners = new Set();
export const onUpdate = (fn) => listeners.add(fn);

export function startRealtime() {
if (!tier.realtime) {
console.log("Real-time is not enabled on this plan, staying on cached polling.");
return;
}

let attempt = 0;

function connect() {
// Get the real URL and subscription message from the WebSocket docs.
const ws = new WebSocket(process.env.ORBISTATS_WS_URL, {
headers: { Authorization: Bearer ${process.env.ORBISTATS_API_KEY} },
});

ws.on("open", () => {
  attempt = 0;
  ws.send(JSON.stringify({ action: "subscribe", sport: "football" }));
});

ws.on("message", (buf) => {
  const event = JSON.parse(buf.toString());
  for (const fn of listeners) fn(event);
});

ws.on("close", () => {
  // Reconnect with exponential backoff, capped at 30 seconds.
  const delay = Math.min(30_000, 1_000 << attempt);
  attempt += 1;
  setTimeout(connect, delay);
});

ws.on("error", (err) => console.warn("WebSocket error:", err.message));
Enter fullscreen mode Exit fullscreen mode

}

connect();
}

Note the first lines of startRealtime. Because the plan is a setting, the very same codebase runs on both the free and the paid tier. On free it politely stays on cached polling. On Starter it switches to the stream. No branches in your business logic, no separate deployment.

To get those events into browsers, forward them with Server-Sent Events:

js
// stream.js
const clients = new Set();

export function mountStream(app) {
app.get("/stream", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.flushHeaders();
clients.add(res);
req.on("close", () => clients.delete(res));
});
}

export function broadcast(event) {
for (const c of clients) c.write(data: ${JSON.stringify(event)}\n\n);
}

Connect the two pieces with one line, onUpdate(broadcast), and every browser tab updates the moment a score changes.

If your product shows prices, add the Odds API to the same pipeline. It returns normalized odds across bookmakers in one schema, so you don't maintain a parser per bookmaker. If you want richer match pages, the Sports Statistics API adds team and player numbers.

Starter go-live checklist

Environment variable ORBISTATS_TIER switched to starter, and the budget limit set from the docs.
WebSocket reconnect tested by killing the connection on purpose.
REST used for initial state, stream used for changes.
Alert when the budget passes 80 percent.
Error pages tested with the API unreachable, to confirm that the stale fallback works.
The status page added to your monitoring routine.
A dashboard or log line showing the data source (cache, live, stale).

Stage 3: Grow into history, webhooks and widgets

Growth is where your product gets deeper rather than just bigger. At the time of writing it adds historical data, widgets and webhooks.

Historical data

The Historical Sports Data API is a multi-season archive (fixtures, results, statistics, lineups, events and closing odds, with seasons going back as far as 2010 for covered data). It unlocks head-to-head history, season form charts, backtesting and machine learning datasets.

The golden rule with history: fetch it once, store it yourself, and never ask for it again. Old seasons don't change, so a one-time backfill is vastly cheaper than hitting the API on every page view.

js
// backfill.js
import { appendFile, readFile } from "node:fs/promises";
import { getData } from "./client.js";

const OUT = "history.jsonl";
const PROGRESS = "history.progress.json";

async function loadProgress() {
try { return JSON.parse(await readFile(PROGRESS, "utf8")); }
catch { return { done: [] }; }
}

async function saveProgress(p) {
const { writeFile } = await import("node:fs/promises");
await writeFile(PROGRESS, JSON.stringify(p));
}

export async function backfill(sport, seasons) {
const progress = await loadProgress();

for (const season of seasons) {
const key = ${sport}:${season};
if (progress.done.includes(key)) continue; // resume support

// PLACEHOLDER: confirm the exact historical path and parameters in the API reference.
const { data } = await getData(`/${sport}/results?season=${season}`, {
  ttlMs: 0,
});

const rows = Array.isArray(data) ? data : (data.data ?? []);
const lines = rows.map((r) => JSON.stringify({ sport, season, ...r })).join("\n");
if (lines) await appendFile(OUT, lines + "\n");

progress.done.push(key);
await saveProgress(progress);
console.log(`Saved ${rows.length} rows for ${key}`);
Enter fullscreen mode Exit fullscreen mode

}
}

// Example: backfill(...) with the seasons you actually need.
// await backfill("football", [2022, 2023, 2024, 2025]);

A few reasons this script is written the way it is. It saves progress after every season, so if it crashes or you hit a limit, you resume instead of starting over. It goes through the same budget-aware client, so you can't accidentally burn your quota. It writes one JSON object per line, which loads easily into a database, a notebook or a data pipeline later.

Also be mindful of usage terms when you store data. Read the data licensing page before you redistribute or display archived data commercially.

Webhooks

Webhooks flip the direction. Instead of you listening, Orbistats calls your server when something happens. They are ideal for notifications, background jobs and anything that shouldn't depend on a browser being open.

js
// webhook.js
import express from "express";

const seen = new Set(); // use Redis or a database in production

export function mountWebhook(app, handleEvent) {
app.post("/webhook/sports", express.json(), (req, res) => {
// 1. Verify the signature using the method described in the webhook docs.
// 2. Ignore duplicates, because delivery systems can send the same event twice.
const id = req.body?.event_id ?? JSON.stringify(req.body);
if (seen.has(id)) return res.sendStatus(200);
seen.add(id);

// 3. Acknowledge immediately, process afterwards.
res.sendStatus(200);
queueMicrotask(() => handleEvent(req.body));
Enter fullscreen mode Exit fullscreen mode

});
}

Three habits protect you here: verify authenticity, make handlers idempotent so a repeated event is harmless, and answer fast. If your endpoint is slow, senders may retry and you get even more duplicates.

Widgets

If part of your product is content (articles, previews, recaps), you don't need to build every score box yourself. Orbistats Widgets let you embed ready-made components such as live scores, a match center and an odds board. A sensible split is custom UI for the screens that define your product and widgets for the long tail of pages.

Growth go-live checklist

Historical data backfilled once and stored on your side.
Webhook endpoint verified, idempotent and fast.
Duplicate event handling tested by replaying one on purpose.
Widget containers reserve height, so pages don't jump when they load.
Licensing terms read for any commercial display of archived data.
Backups of your stored history.

Stage 4: Enterprise and beyond

Enterprise is for teams whose needs have moved past a published tier: very high volume, dedicated infrastructure, custom feeds, specific support expectations or contractual requirements. There is no code change to talk about here, because if you followed the earlier stages your integration is already plan-agnostic. What you do need is a clear conversation.

Before you contact sales (the address on the site is sales@orbistats.com), prepare answers to these questions:

Which sports and competitions do you need, and how many requests per day or events per second do you expect at peak?
Which delivery methods matter most: REST, WebSocket, webhooks or all three?
What latency do you need, and what are you measuring today?
Do you need historical depth, bulk exports or custom data feeds?
What support, uptime and reporting expectations does your business have?
What will your usage look like at launch, in six months and in twelve?

The estimator script from earlier turns directly into the volume numbers for that conversation. Teams that arrive with data tend to get faster, more useful answers.

A clean way to switch plans without drama

The best migrations are boring. Here is the sequence that keeps them boring.

Create separate keys for development, staging and production, and never share a key across environments.
Keep every plan-specific behaviour behind the tier configuration, so an upgrade is a settings change.
Upgrade in staging first and run your full test flow, including failure cases.
Switch production during a quiet period, not an hour before a big match.
Watch the usage endpoint and the data-source header for the first day.
Keep the old behaviour available as a fallback for a week, so you can roll back by changing one variable.
Only then remove the workarounds you built for the lower tier, such as the extreme cache lifetimes.

Step seven is the one people skip, and it's the satisfying part. After upgrading, relax your caching, lower your live latency and let the product feel the way you originally imagined.

Common mistakes to avoid

Calling the API from the browser, which exposes your key and bypasses your cache.
Using one key for everything, so a staging bug can burn production quota.
Treating the daily limit as a problem to discover at launch instead of a design constraint from day one.
Polling every few seconds for data that rarely changes.
Re-fetching old seasons on every request instead of storing them once.
Building the whole app around one plan's behaviour, which makes upgrading painful.
Skipping the fallback, so one failed request turns into a blank screen.
Upgrading in a panic during a spike instead of planning ahead from your own usage numbers.
Ignoring versioning. Orbistats uses versioned paths such as /v1/, so pin your integration to a version and read the changelog before changing it.

Quick recap

Stay on Free while you validate the idea, and make it last with caching, de-duplication and a request budget.

Move to Starter when you need genuinely live data in production, and switch from polling to WebSockets.

Move to Growth when you need history, push delivery with webhooks, or embeddable widgets.

Talk to sales about Enterprise when your volume, latency or support needs go beyond a published plan.

And through every stage, keep the plan as a configuration setting, keep your key on the server, and keep the browser talking to your own backend.

Where to go next

Read the documentation for authentication, versioning and limits.
Try responses in the Sandbox before you write code.
Explore the API reference for the exact endpoints and parameters.
Compare tiers on the pricing page.

Ready to start? Grab a free API key and run the estimator on your own project today. You'll know in five minutes which stage you're really at.

Over to you: what was the moment you realised your side project needed a real plan, a traffic spike, a client demo, or something else? Share your story in the comments, and tell me which part of the migration you'd like a deeper dive on.

Top comments (0)