Guide

Betting odds in Google Sheets

An Apps Script that fills a sheet with upcoming football odds and refreshes it on a timer. The API key stays in Script Properties, not in the sheet or the code.

Before you start

You need an API key; see Authentication. Anyone with edit access to the spreadsheet can open its script project and read Script Properties, so use a key only for this sheet and share the file with care. A restricted REST-only key limits the damage if it leaks.

Which bookmakers your key can read depends on your plan and bookmaker entitlements; see Bookmakers for the list your key returns. No bookmaker is in verified customer coverage right now. The examples use bookmaker=tipico and football. Examples use Tipico for illustration only. Tipico ingestion is offline for a source review and is not part of current customer coverage.

Store the key in Script Properties

  1. In the spreadsheet, open Extensions → Apps Script.
  2. Open Project Settings (gear icon) and scroll to Script Properties.
  3. Add the property BETSPREAD_API_KEY with your key (YOUR_API_KEY) as value and save.

Add the script

Replace the contents of Code.gs with the script below. refreshOdds pages through GET /api/v1/events from now on, reads GET /api/v1/events/{eventId}/odds for the first 5 events and writes one row per outcome to a sheet named Odds.

UrlFetchApp runs with muteHttpExceptions so the script can read the error code. 429 and 503 are retried after Retry-After seconds when that wait is short; longer waits, such as a used-up monthly quota, are left to the next run. Other errors stop the run with the error code in the execution log.

Code.gs
const API_BASE = "https://api.betspread.io/api/v1";
const SHEET_NAME = "Odds";
const MAX_EVENTS = 5;
const MAX_WAIT_SECONDS = 30;

function refreshOdds() {
  const events = [];
  const params = { sport: "football", dateFrom: new Date().toISOString(), limit: 100, offset: 0 };
  while (events.length < MAX_EVENTS) {
    const page = betspreadGet_("/events", params);
    events.push(...page.data);
    params.offset += page.meta.limit;
    if (page.data.length === 0 || params.offset >= page.meta.total) break;
  }

  const rows = [["Kickoff (UTC)", "League", "Home", "Away", "Market", "Line", "Bookmaker", "Outcome", "Odds", "Last updated"]];
  for (const event of events.slice(0, MAX_EVENTS)) {
    const oddsParams = { bookmaker: "tipico" };
    const odds = betspreadGet_(`/events/${event.id}/odds`, oddsParams).data;
    for (const market of odds.markets) {
      for (const bookmaker of market.bookmakers) {
        for (const outcome of bookmaker.outcomes) {
          rows.push([
            event.kickoffAt, event.leagueName, event.homeTeam, event.awayTeam,
            market.type, market.line ?? "", bookmaker.displayName,
            outcome.outcome, outcome.odds, outcome.lastUpdatedAt,
          ]);
        }
      }
    }
  }

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName(SHEET_NAME) || spreadsheet.insertSheet(SHEET_NAME);
  sheet.clearContents();
  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
}

function betspreadGet_(path, params) {
  const apiKey = PropertiesService.getScriptProperties().getProperty("BETSPREAD_API_KEY");
  if (!apiKey) throw new Error("Add the BETSPREAD_API_KEY script property first.");
  const query = Object.keys(params)
    .map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(params[name])}`)
    .join("&");

  for (let attempt = 1; attempt <= 3; attempt++) {
    const response = UrlFetchApp.fetch(`${API_BASE}${path}?${query}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
      muteHttpExceptions: true,
    });
    const status = response.getResponseCode();
    if (status === 200) return JSON.parse(response.getContentText());

    let code = "unknown";
    try {
      code = JSON.parse(response.getContentText()).error.code;
    } catch (parseError) {
      // Not a betspread error body.
    }
    const retryAfter = Number(header_(response, "Retry-After") || 2 ** attempt);
    if ((status !== 429 && status !== 503) || retryAfter > MAX_WAIT_SECONDS) {
      // Long waits are left to the next trigger run.
      throw new Error(`betspread ${status} ${code}`);
    }
    Utilities.sleep(retryAfter * 1000);
  }
  throw new Error("betspread: retries exhausted");
}

function header_(response, name) {
  const headers = response.getHeaders();
  const match = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase());
  return match ? headers[match] : null;
}

Run refreshOdds once from the editor. Google asks you to authorize external requests and access to the spreadsheet.

Refresh on a trigger

Add this function to the same file and run it once. It replaces any earlier refreshOdds trigger with one that runs every 6 hours. You can also create the trigger by hand under Triggers (clock icon) as a time-driven trigger.

Code.gs — trigger
function installTrigger() {
  for (const trigger of ScriptApp.getProjectTriggers()) {
    if (trigger.getHandlerFunction() === "refreshOdds") ScriptApp.deleteTrigger(trigger);
  }
  ScriptApp.newTrigger("refreshOdds").timeBased().everyHours(6).create();
}

Budget your quota

Every run makes one /events request plus one odds request per event: 6 requests. Every 6 hours that is 720 requests in a 30-day month, within the Free plan's 1,000 monthly REST requests. Raising MAX_EVENTS or the trigger frequency multiplies that number; check your plan on Rate limits first.

Troubleshooting

  • 401 invalid_api_key: the key in Script Properties is mistyped, expired or revoked. A missing property stops the script before any request is sent.
  • 403 forbidden or 403 api_key_scope_denied: your plan or key does not cover this sport or bookmaker; see Bookmakers.
  • An empty sheet below the header: no upcoming events with odds from your entitled bookmakers right now.