Skip to content

Task guide

Import data from a web API into Sheets

The script reads Open-Meteo once and replaces only Forecast!A1:C12 after checking the response and the destination. It preserves the source and licence links beside the forecast.

1 · Prepare

Put these pieces in place

  • Use a new disposable Google spreadsheet named Forecast practice. Rename its blank tab Forecast and reserve the unmerged range A1:C12 for this example; do not use an existing business workbook.
  • Use the free endpoint only for an eligible noncommercial exercise under https://open-meteo.com/en/terms. No key is needed, but commercial use and higher-volume access have separate requirements.
  • Keep the supplied public Berlin coordinates for the first run. Coordinates, ordinary request headers and Google outbound IP information are visible to the provider; no spreadsheet data or Google token is sent.
  • Keep Open-Meteo attribution, the CC BY 4.0 link and the selection description beside copied forecast data. Describe further changes you make. Data licensing at https://open-meteo.com/en/licence is separate from permission to use the free service.

Change these values

Change these values

Make the code yours before you run it.
Forecast length
FORECAST_DAYS = 7

A whole number from 1 to 7. The fixed output area leaves unused daily rows blank when fewer days are requested.

Public location
FORECAST_LATITUDE = 52.52; FORECAST_LONGITUDE = 13.41

Numeric latitude from -90 to 90 and longitude from -180 to 180. Use a public location rather than disclosing a home address.

2 · Review before running

What the script can access and change

Permissions you approve

  • script.external_request permits outbound HTTPS requests. Google consent does not grant rights under Open-Meteo terms; this source requests one public forecast without credentials.
  • spreadsheets.currentonly permits access to the bound spreadsheet, not just the output range. The code, rather than the scope, limits writes to Forecast!A1:C12. A manual run acts as the signed-in user who clicks Run.

Effects of one run

  • Sends at most one GET to api.open-meteo.com with the coordinates, variables, units, time-zone choice and day count. It has no automatic retry and sends no spreadsheet ID, cell values or Google access token.
  • Checks the destination before the request and again before the write. It rejects a missing tab, merged output or nonblank unmarked range rather than clearing existing data. The ownership marker is an output convention, not a lock or security boundary.
  • After checking status, content type, parsing size, JSON, array lengths, dates, units and numeric temperatures, writes one 12-by-3 rectangle. It does not clear the tab, create a sheet or write outside A1:C12. Blank padding removes old daily rows within the reserved area.
  • A failure before setValues leaves earlier output unchanged and possibly stale. A service call can still fail during the write; inspect the actual output before deciding whether to repeat it.

3 · Copy

Paste this into the Apps Script editor

Create or open a script file, replace the example values marked above, then save the project.

Code.gs
const FORECAST_DAYS = 7;
const FORECAST_LATITUDE = 52.52;
const FORECAST_LONGITUDE = 13.41;
const FORECAST_OWNER_MARKER = 'Owned by weather-import';

/**
 * A property-access view for validation, not a validated provider response.
 * Every data field remains unknown until the checks in forecastRows succeed.
 * @typedef {{daily?: {time?: unknown, temperature_2m_max?: unknown, temperature_2m_min?: unknown}|null, daily_units?: {temperature_2m_max?: unknown, temperature_2m_min?: unknown}|null}} ForecastCandidate
 */

function importForecastToSheet() {
  if (!Number.isInteger(FORECAST_DAYS) || FORECAST_DAYS < 1 || FORECAST_DAYS > 7) {
    throw new Error('Set FORECAST_DAYS to a whole number from 1 to 7.');
  }
  if (!Number.isFinite(FORECAST_LATITUDE) || Math.abs(FORECAST_LATITUDE) > 90 ||
      !Number.isFinite(FORECAST_LONGITUDE) || Math.abs(FORECAST_LONGITUDE) > 180) {
    throw new Error('Use numeric latitude from -90 to 90 and longitude from -180 to 180.');
  }

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('Open this project through Extensions > Apps Script in the practice spreadsheet.');
  }
  const sheet = spreadsheet.getSheetByName('Forecast');
  if (!sheet) {
    throw new Error('Create a blank tab named Forecast before running this function.');
  }
  const target = sheet.getRange('A1:C12');
  checkForecastDestination(target, sheet);

  const query = [
    'latitude=' + encodeURIComponent(FORECAST_LATITUDE),
    'longitude=' + encodeURIComponent(FORECAST_LONGITUDE),
    'daily=temperature_2m_max,temperature_2m_min',
    'temperature_unit=celsius',
    'timezone=auto',
    'forecast_days=' + FORECAST_DAYS
  ].join('&');
  const response = UrlFetchApp.fetch('https://api.open-meteo.com/v1/forecast?' + query, {
    method: 'get',
    headers: { Accept: 'application/json' },
    muteHttpExceptions: true,
    followRedirects: false
  });
  const statusCode = response.getResponseCode();
  if (statusCode !== 200) {
    throw new Error('The forecast service answered with status code ' + statusCode + '. No cells were written.');
  }
  const contentType = forecastHeader(response.getAllHeaders(), 'content-type');
  if (contentType.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new Error('The forecast response was not application/json. No cells were written.');
  }
  const text = response.getContentText('UTF-8');
  if (text.length > 50000) {
    throw new Error('The forecast response exceeded the 50000-character parsing limit.');
  }
  /** @type {unknown} */
  let forecast;
  try {
    forecast = JSON.parse(text);
  } catch (error) {
    throw new Error('The forecast response was not valid JSON. No cells were written.');
  }

  const rows = forecastRows(forecast, FORECAST_DAYS);
  // Check again after the request in case the destination changed while it was pending.
  checkForecastDestination(target, sheet);
  target.setValues(rows);
  Logger.log('Wrote ' + FORECAST_DAYS + ' days of forecast.');
}

/**
 * @param {SpreadsheetApp.Range} target
 * @param {SpreadsheetApp.Sheet} sheet
 * @returns {void}
 */
function checkForecastDestination(target, sheet) {
  if (target.isPartOfMerge()) {
    throw new Error('Forecast!A1:C12 must not overlap merged cells.');
  }
  if (target.isBlank()) return;
  const markerCell = sheet.getRange('A12');
  if (markerCell.getValue() !== FORECAST_OWNER_MARKER || markerCell.getFormula() !== '') {
    throw new Error('Forecast!A1:C12 is not blank and has no valid ownership marker. Use a new practice sheet; do not clear existing data to bypass this check.');
  }
}

/**
 * @param {unknown} rawForecast
 * @param {number} expectedDays
 * @returns {Array<Array<string|number>>}
 */
function forecastRows(rawForecast, expectedDays) {
  // This view permits property probes; the original runtime checks still validate the value.
  const forecast = /** @type {ForecastCandidate|null|undefined} */ (rawForecast);
  if (!forecast || typeof forecast !== 'object' || Array.isArray(forecast) ||
      !forecast.daily || !forecast.daily_units) {
    throw new Error('The forecast response is missing daily data or units.');
  }
  const days = forecast.daily.time;
  const highs = forecast.daily.temperature_2m_max;
  const lows = forecast.daily.temperature_2m_min;
  if (!Array.isArray(days) || !Array.isArray(highs) || !Array.isArray(lows) ||
      days.length !== expectedDays || highs.length !== expectedDays || lows.length !== expectedDays) {
    throw new Error('The forecast arrays must each contain exactly ' + expectedDays + ' days.');
  }
  if (forecast.daily_units.temperature_2m_max !== '°C' ||
      forecast.daily_units.temperature_2m_min !== '°C') {
    throw new Error('The forecast temperature units must be Celsius.');
  }

  /** @type {Array<Array<string|number>>} */
  const rows = [['Date', 'High', 'Low']];
  let index = 0;
  /** @type {number|null} */
  let previousDay = null;
  while (index < expectedDays) {
    /** @type {unknown} */
    const day = days[index];
    /** @type {unknown} */
    const high = highs[index];
    /** @type {unknown} */
    const low = lows[index];
    if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) {
      throw new Error('Forecast day ' + (index + 1) + ' is not a YYYY-MM-DD date.');
    }
    const timestamp = Date.parse(day + 'T00:00:00Z');
    if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== day ||
        (previousDay !== null && timestamp - previousDay !== 86400000)) {
      throw new Error('Forecast dates must be valid consecutive calendar days.');
    }
    if (!Number.isFinite(high) || !Number.isFinite(low) ||
        /** @type {number} */ (high) < /** @type {number} */ (low)) {
      throw new Error('Forecast day ' + (index + 1) + ' needs numeric high and low temperatures with high >= low.');
    }
    rows.push([day, /** @type {number} */ (high), /** @type {number} */ (low)]);
    previousDay = timestamp;
    index = index + 1;
  }
  // The fixed output area has room for seven days, even when fewer are requested.
  while (rows.length < 8) {
    rows.push(['', '', '']);
  }
  rows.push(['Weather data by Open-Meteo', 'https://open-meteo.com/', '']);
  rows.push(['Licence: CC BY 4.0', 'https://creativecommons.org/licenses/by/4.0/', '']);
  rows.push(['Selected daily highs/lows; Celsius; local dates', '', '']);
  rows.push([FORECAST_OWNER_MARKER, '', '']);
  return rows;
}

/**
 * @param {Record<string, string|string[]>} headers
 * @param {string} wantedName
 * @returns {string}
 */
function forecastHeader(headers, wantedName) {
  for (const name of Object.keys(headers)) {
    if (name.toLowerCase() === wantedName) {
      const value = headers[name];
      if (Array.isArray(value)) return value.join(', ');
      return String(value);
    }
  }
  return '';
}

4 · Run safely

Follow these steps in order

  1. Install the whole source in the new spreadsheet

    Open Extensions > Apps Script from Forecast practice. Replace all of Code.gs with the complete source, including its constants and every helper. Leave Forecast!A1:C12 initially blank and unmerged; do not type the ownership marker into an existing table.

  2. Set the runtime and permissions explicitly

    In Project Settings, show appsscript.json in the editor. Replace its contents with {"timeZone":"Etc/UTC","dependencies":{},"exceptionLogging":"STACKDRIVER","runtimeVersion":"V8","oauthScopes":["https://www.googleapis.com/auth/script.external_request","https://www.googleapis.com/auth/spreadsheets.currentonly"]}. Save both files. No advanced service, library, deployment or trigger is needed.

  3. Run the seven-day baseline

    Keep FORECAST_DAYS at 7 and the supplied coordinates. Select importForecastToSheet and click Run as the owner of this disposable spreadsheet. Review the request for current-spreadsheet and external-request access. Do not overlap runs or manually edit the output while it runs.

  4. Inspect the forecast and its attribution

    Expect Date, High and Low in row 1 and seven live daily forecasts in rows 2-8. Rows 9-12 hold the source link, licence link, selection description and ownership marker. The log should say Wrote 7 days of forecast. Exact dates and temperatures vary.

  5. Predict a shorter forecast, then restore it

    Change FORECAST_DAYS to 3 and run once. Expect three daily rows and blank A5:C8, with attribution and marker rows retained. Restore 7 and run once more. As a no-request check, set the value to 8: it should fail before fetching or writing. Restore 7 before continuing.

  6. Keep the output boundary and retire only practice data

    Later runs replace values throughout the marked A1:C12 rectangle, including anything you added there. Keep other notes outside it. If a write or completion is uncertain, inspect this range before another run; the request and Sheets write do not share a transaction. When finished, remove only the disposable spreadsheet you created, after confirming nothing unrelated was added.

5 · Confirm

How to tell it worked

Forecast!A1:C8 contains headers and seven consecutive local-date forecasts in Celsius, with attribution, licence, selection notes and an ownership marker in rows 9-12. The execution log reports Wrote 7 days of forecast. A three-day run blanks the unused daily rows rather than leaving an older forecast below them.

If the result is different

Troubleshooting

The destination is missing, merged or nonblank without a valid marker.
Check that this is the new disposable spreadsheet and its blank unmerged Forecast tab. Do not clear unrelated data or forge the marker to bypass the refusal. After a successful first write, preserve the marker and reserve the entire output rectangle.
The forecast service returns a non-200 status or the fetch throws.
Read the status or actual exception without logging a private response body. A 400 suggests invalid request parameters; authorization and quota errors need their own correction. For rate or service failures, inspect the provider documentation and remaining quota before trying again later. Do not add a blind retry loop.
The response has the wrong content type, JSON, dates, units or temperatures.
The script refuses to turn unexpected data into cells. Retain the error category and safe request settings, compare them with https://open-meteo.com/en/docs, and leave prior output unchanged rather than converting missing values to zero or removing validation.
My notes inside the output area changed on a repeat.
Once marked, A1:C12 belongs to the script. Keep manual notes outside that rectangle. The marker does not distinguish notes added later from generated output, and the code does not preserve them.
The run ended without a clear successful write.
Inspect Forecast!A1:C12 and the execution history before running again. Earlier data may be stale and a failed completion signal does not prove that no cells changed. Do not clear the range as an automatic recovery step.

Technical details

Names used in this script

Open these details when you want to adapt the example or look up an exact service or method name.

Show Apps Script symbols and service pages

Keep going