Google Sheets Screenshots with IMAGE() and Apps Script

Show website screenshots in Google Sheets with IMAGE() and a custom function that signs each URL in Apps Script, so no API key sits in a cell.

By Simon Chiu · Updated

Google Sheets can show any image URL with IMAGE(). A RenderScreenshot GET URL returns the image itself, so a website screenshot is one formula. The question is the key. This page adds an Apps Script custom function that builds a signed URL for each cell. Formulas stay short, and the sheet holds no API key. It is part of our integrations hub.

=IMAGE(SCREENSHOT_URL(A2, "og_card", "", RS_REFRESH))

Last tested: 2026-10-18. We ran Code.gs with Node 22.18 inside a VM context that provides PropertiesService and a Utilities.computeHmacSha256Signature that returns signed bytes, as Apps Script does. A local stub of our API, which checks signatures with the documented algorithm, accepted all generated URLs and rejected a changed one with invalid_signature. The same hex conversion gives the documented reference signature. We did not run it inside a Google Sheet for this page, so it has no screenshots of Sheets.

What you can build

  • A link audit sheet. Column A holds URLs from a sitemap. Column B shows each page, so broken layouts are visible in one scroll.
  • A partner or app directory draft. Each row gets a thumbnail before the data goes to your CMS.
  • Text cards from cell values. HTML_IMAGE_URL renders a short HTML snippet built with CONCATENATE as an image. Use it for status badges or a quick social card mock-up.

IMAGE() only shows a remote image; the sheet stores no file. To keep a copy of each capture in the record itself, use the Airtable attachment script instead.

Prerequisites

  • A RenderScreenshot signing key pair: a public key ID that starts with rs_pub_ and a secret key that starts with rs_secret_. Create a free account, then create the pair on the API Keys page. See API keys.
  • Edit access to the spreadsheet, to add the script.

Step by step

  1. Open the sheet and choose Extensions > Apps Script.
  2. Replace the content of Code.gs with the script below and save.
  3. Open Project Settings > Script properties. Add RS_PUBLIC_KEY_ID and RS_SECRET_KEY.
  4. In the sheet, select an empty cell, for example Settings!B1. Choose Data > Named ranges and name it RS_REFRESH.
  5. In the script editor, run installWeeklyRefresh once. Google asks you to authorize the trigger and spreadsheet access. The custom functions do not need this, but the trigger does.
  6. In the sheet, enter =IMAGE(SCREENSHOT_URL(A2, "og_card", "", RS_REFRESH)).
// RenderScreenshot for Google Sheets: signed screenshot URLs for IMAGE().
// Script properties (Project Settings > Script properties):
//   RS_PUBLIC_KEY_ID  rs_pub_...
//   RS_SECRET_KEY     rs_secret_...
const RS_ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot';
const WEEK_SECONDS = 7 * 24 * 3600;

/**
 * Returns a signed screenshot URL for a web page. Wrap it in IMAGE().
 *
 * @param {string} url The page to capture, starting with https://
 * @param {string} preset Optional preset, for example "og_card" or "link_preview"
 * @param {string} options Optional extra GET parameters, for example "format=webp&block_cookies=true"
 * @param {any} refresh Optional cell that a weekly trigger changes, so links are re-signed
 * @return The signed URL
 * @customfunction
 */
function SCREENSHOT_URL(url, preset, options, refresh) {
  if (!url) return '';
  const params = parseOptions_(options);
  params.url = String(url).trim();
  if (preset) params.preset = String(preset);
  return signedUrl_(params);
}

/**
 * Returns a signed URL that renders a short HTML snippet as an image.
 *
 * @param {string} html The HTML to render
 * @param {number} width Viewport width in pixels
 * @param {number} height Viewport height in pixels
 * @param {any} refresh Optional cell that a weekly trigger changes
 * @return The signed URL
 * @customfunction
 */
function HTML_IMAGE_URL(html, width, height, refresh) {
  if (!html) return '';
  return signedUrl_({ html: String(html), width: width || 1200, height: height || 630 });
}

function signedUrl_(params) {
  const props = PropertiesService.getScriptProperties();
  const keyId = props.getProperty('RS_PUBLIC_KEY_ID');
  const secret = props.getProperty('RS_SECRET_KEY');
  if (!keyId || !secret) throw new Error('Set RS_PUBLIC_KEY_ID and RS_SECRET_KEY in Script properties');

  // Reuse an earlier identical render for 0 credits, for up to 30 days.
  params.cache_available = 'true';
  params.cache_ttl = '2592000';

  // Same expiry for a whole week, so the URL (and the image Sheets caches) stays the same.
  // It lands 21 to 28 days ahead, inside the 30-day maximum.
  const now = Math.floor(Date.now() / 1000);
  params.expires = String(Math.floor(now / WEEK_SECONDS) * WEEK_SECONDS + 4 * WEEK_SECONDS);
  params.key_id = keyId;

  const query = Object.keys(params)
    .map(function (k) { return encode_(k) + '=' + encode_(params[k]); })
    .sort()
    .join('&');
  const bytes = Utilities.computeHmacSha256Signature(query, secret);
  const hex = bytes.map(function (b) { return ('0' + (b & 0xff).toString(16)).slice(-2); }).join('');
  return RS_ENDPOINT + '?' + query + '&signature=' + hex;
}

// Encode like the API: space -> "+", only A-Z a-z 0-9 - _ . ~ stay as they are.
function encode_(value) {
  return encodeURIComponent(String(value))
    .replace(/[!'()*]/g, function (c) { return '%' + c.charCodeAt(0).toString(16).toUpperCase(); })
    .replace(/%20/g, '+');
}

function parseOptions_(options) {
  const out = {};
  if (!options) return out;
  String(options).split('&').forEach(function (pair) {
    const i = pair.indexOf('=');
    if (i > 0) out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
  });
  return out;
}

// Run once from the editor: creates the weekly trigger that re-signs every link.
function installWeeklyRefresh() {
  ScriptApp.newTrigger('bumpRefreshCell').timeBased().everyWeeks(1).onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(3).create();
  bumpRefreshCell();
}

// Writes the current date into the named range RS_REFRESH. Formulas that pass it as `refresh` recalculate.
function bumpRefreshCell() {
  const range = SpreadsheetApp.getActive().getRangeByName('RS_REFRESH');
  if (!range) throw new Error('Create a named range called RS_REFRESH (one cell)');
  range.setValue(new Date().toISOString().slice(0, 10));
}

Why the expiry is rounded to the week

Every signed URL needs expires, at most 30 days ahead. If the function used "now + 30 days", each recalculation would make a new URL. Sheets would fetch the image again, and the link would change for no reason. The script instead rounds to the start of the current week and adds four weeks. All cells signed in the same week get the same URL, and each link stays valid for 21 to 28 days.

A custom function runs again only when its arguments change. Google does not let you pass NOW() to one. This is why each formula takes RS_REFRESH as its last argument. The weekly trigger writes the date into that cell, and every formula that refers to it is signed again. If the trigger fails once, the old links still work for three more weeks.

Parameter mapping

Function argument API parameter Example
url url A2, or "https://example.com"
preset preset "og_card", "link_preview", "mobile". See presets.
options any flat GET parameter "format=webp&block_cookies=true&dark_mode=true"
refresh none RS_REFRESH, only to start a recalculation
(fixed) cache_available, cache_ttl true and 2592000, so repeat fetches cost 0 credits for 30 days

options takes the names from the GET endpoint reference. Write booleans as true. Options without a GET name, such as wait-for-selector or cookies, cannot be signed into a URL.

For HTML, use =IMAGE(HTML_IMAGE_URL("<h1>"&A2&"</h1>", 1200, 630, RS_REFRESH)). Keep the HTML short: the whole document travels in the URL.

Where the key lives, and who can read it

The secret key is in Script properties, not in a cell. Viewers and commenters cannot open the Apps Script project. Editors can, and they can read the properties. Share edit access only with people you would trust with the signing key. If an editor leaves, revoke the pair in the dashboard; revoking one key of a pair revokes both.

The URLs in the cells are public in a smaller way. Each one renders exactly the page and options it was signed for, until it expires. Nobody can change its parameters without breaking the signature.

Errors and retries

IMAGE() shows an empty cell for any failed image and does not show the reason. To see it, copy the URL with =SCREENSHOT_URL(A2) in a spare cell and open it in a browser tab. The JSON error names the code.

Code Cause in Sheets Fix
invalid_signature The URL was edited after signing, or the properties hold the wrong secret Do not change the returned URL. Check both properties.
expired_signature The weekly trigger stopped and the cells were not signed again Run bumpRefreshCell by hand, then check Triggers
invalid_url The cell holds example.com without https:// Use "https://"&A2
toomanyrequests The sheet asked for more images in one minute than your plan allows See limits below
timeout The page loads slowly Add timeout=45 to options

Limits and costs

  • Requests per minute. Each image that Sheets fetches is one request to the API, counted per account. The free plan allows 10 per minute. A new column of hundreds of images can go past that, and the cells that got 429 stay empty until Sheets fetches them again. Add images in blocks, or use a plan with a higher limit. See rate limits.
  • Credits. The first fetch of each URL and option set renders and uses one credit. Later fetches within 30 days reuse that render for 0 credits, even after the weekly re-sign, because the cache key comes from the rendering options. The free plan gives 150 credits each month. See pricing.
  • Freshness. The flip side of the 30-day reuse: an image can be up to 30 days old. Lower cache_ttl in the script if your pages change often.
  • Apps Script. A custom function must return within 30 seconds. Signing takes far less, because the function only computes an HMAC and never calls the API.

Troubleshooting

  • #ERROR! with "Set RSPUBLICKEYID and RSSECRET_KEY". The script properties are missing or misspelled.
  • #NAME?. The script is not saved, or the function name has a typo.
  • The cell shows Loading... for a long time. A volatile function such as NOW() was passed as an argument. Use RS_REFRESH instead.
  • The image shows a cookie wall. Add block_cookies=true to options.

Frequently asked questions

How do I show a website screenshot in a Google Sheets cell?
Put an image URL inside IMAGE(). A screenshot API URL works because GET /v1/screenshot returns the image itself. Use a custom function to build a signed URL, so the formula is =IMAGE(SCREENSHOT_URL(A2)) and the sheet never holds an API key.
Why not put my API key in the formula?
Everyone who can open the sheet can read every formula, and the key would work for every endpoint on your account. A signed URL only allows the one screenshot it describes, and it expires.
Can Google Sheets turn HTML into an image?
Yes, for short snippets. The HTML_IMAGE_URL function on this page signs a GET request with the html parameter, and IMAGE() shows the result. Long documents do not fit in a URL. Render those on a server with POST.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.