BlogBuilding betting tools

Sports API Widgets Without Exposing Your Key

Sports API widgets that show odds on a website should follow one pattern: your server calls the API with the key, caches the answer for a short time, and hands the browser a trimmed JSON of its own. The browser renders your component from that. The key never reaches the page, and a thousand visitors cost you one request, not a thousand.

Most of the mistakes in this area come from skipping that middle step and calling the API straight from the page. That leaks the key to anyone who opens developer tools, and it multiplies your usage by your traffic. This page shows the server side, the cache, and what the component should display.

Why the key can't go in the browser

Anything a browser runs, a visitor can read. Put an API key in a script, a config file or a request header sent from the page, and it's public. Someone can copy it and spend your credits, or hit your rate limit until your own site stops loading.

ZippyOddsAPI already refuses a key sent in a URL, with 400 api_key_in_url, because URLs end up in logs. That protects against one leak. It can't protect a key you ship to the browser yourself. The stream does accept a key in its first message from a browser, since browsers can't set WebSocket headers, but that still puts the key in the page, so relay it through your server instead.

Two settings on a key help limit the damage if one does leak: scopes, which restrict it to the endpoints you name, and an IP allowlist, which restricts it to your server's addresses. A key used only for a widget needs only the endpoints the widget calls. The docs explain both.

The server side

Here's a small Node handler (Node 18 or later, which has fetch built in) that reads NBA moneylines from ZippyOddsAPI, caches them for 30 seconds, and returns only what the widget needs.

const KEY = process.env.ZIPPYODDS_KEY;
const TTL_MS = 30_000;
let cache = null;
let pending = null;

async function loadNbaMoneylines() {
  const res = await fetch(
    "https://zippyoddsapi.com/v1/odds?league=nba&markets=moneyline",
    { headers: { Authorization: `Bearer ${KEY}` } },
  );
  const json = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${json.error?.code}`);
  return {
    generatedAt: json.meta.generated_at,
    games: json.data.map((event) => ({
      id: event.id,
      start: event.start_time,
      home: event.home.name,
      away: event.away.name,
      books: event.bookmakers.map((book) => ({
        name: book.name,
        updatedAt: book.updated_at,
        prices: Object.fromEntries(
          book.markets
            .filter((m) => m.key === "moneyline")
            .flatMap((m) => m.outcomes.map((o) => [o.side, o.price])),
        ),
      })),
    })),
  };
}

export async function widgetData() {
  if (cache && Date.now() - cache.at < TTL_MS) return cache.body;
  pending ??= loadNbaMoneylines()
    .then((body) => { cache = { at: Date.now(), body }; return body; })
    .finally(() => { pending = null; });
  try {
    return await pending;
  } catch (err) {
    if (cache) return cache.body; // last good copy, at its real age
    throw err;
  }
}

Wire widgetData() to a route on your own site, such as /odds-widget.json, and have the page fetch that. A few details worth keeping:

  • One request in flight. The pending promise means that if a hundred visitors arrive the moment the cache expires, the API gets one call, not a hundred.
  • Errors by code. A failed answer has an error object with a stable code. Branch on it, and on failure keep serving the last good copy with its real age rather than an empty widget.
  • Prices are American odds by default. Add format=decimal to the query if your audience prefers decimal.
  • Paging. The odds endpoint returns up to 200 events per page for game markets and says in meta.has_more whether more remain. A single league's slate usually fits. Check it anyway.

What the cache saves

The odds endpoint costs 2 credits a request, whatever the size of the answer. Say the page gets 10,000 views an hour:

  • Calling the API on every view: 10,000 × 2 = 20,000 credits an hour, and 10,000 requests against your rate limit.
  • Caching for 30 seconds: 3,600 / 30 = 120 requests an hour, or 240 credits.

The data barely suffers. Prices are polled from each venue, so they're minutes old rather than milliseconds at the source. A 30-second cache adds little to that. Pick the length to match how often you want the widget to change, not how often visitors arrive. Credits and rate limits differ by plan, and the pricing page has the details.

Rendering your own component

ZippyOddsAPI provides the API and the stream; the docs don't describe a ready-made embeddable widget, so the component is yours to build. That's an advantage: it matches your site, and you decide what it shows. Whatever framework you use, it should show four things:

  1. The game. Teams and start time, in the visitor's time zone.
  2. The prices. One row per book, with the best price on each side marked.
  3. The age. "Read 40 seconds ago", worked out from each book's updatedAt against the response's generatedAt, so a visitor's wrong clock doesn't distort it. Grey out prices past a limit you choose.
  4. The basics. An age notice and a responsible-gambling link next to any betting odds, and wording that doesn't suggest you're taking bets.

Don't hide a book's price because it's old. Show it at its real age. A widget that drops stale prices quietly makes the best-price column look better than the market really is.

Where to go next

If your back end is in Python rather than Node, the sports betting API python walkthrough covers the same request, paging and error handling. If you want the server to watch prices and notify you rather than render them, see the guide to building a sports betting bot. And for the full picture of what an odds API returns, start with what a sports betting API gives you.

The pattern behind all good sports api widgets is the same: the key and the cache live on the server, and ZippyOddsAPI's timestamps travel all the way to the screen.