Menu
Sign up

Screenshot API in JavaScript: Node fetch, signed URLs, webhooks

Take website screenshots from Node.js with fetch: full page, device presets, element capture, cookie banners, polling, webhooks and signed img URLs.

Published November 4, 2026 · 7 min read

To take a website screenshot from JavaScript, send a POST to https://shareglint.com/v1/screenshots?wait=true from Node 18 or later with the built-in fetch, an Authorization: Bearer sk_live_… header and a JSON body such as {"url": "https://example.com", "full_page": true}. The response is JSON with a url pointing at the finished PNG, JPEG, WebP or PDF. In the browser, never use the API key: build a signed /s/ URL on the server and put it in an <img src>.

Everything below runs on plain Node — no SDK to install. The calls were tested against the API with Node 20 and Express 5.

One screenshot with fetch

const API = 'https://shareglint.com/v1';
const headers = {
  Authorization: `Bearer ${process.env.SHOT_API_KEY}`,
  'Content-Type': 'application/json',
};

export async function screenshot(options) {
  const res = await fetch(`${API}/screenshots?wait=true`, {
    method: 'POST',
    headers,
    body: JSON.stringify(options),
    signal: AbortSignal.timeout(60_000), // fetch has no overall timeout of its own
  });
  const body = await res.json();

  if (res.status === 503 && body.id) return waitFor(body.id); // sync window ran out: poll instead
  if (!res.ok) throw new Error(`${res.status} ${body.error ?? ''} ${body.message ?? ''}`.trim());
  return body; // { id, kind: 'screenshot', status: 'completed', url, ... }
}

const shot = await screenshot({ url: 'https://example.com', viewport_width: 1200, viewport_height: 630 });
await writeFile('example.png', Buffer.from(await (await fetch(shot.url)).arrayBuffer()));

(writeFile comes from node:fs/promises; waitFor is defined in the polling section.) AbortSignal.timeout() matters: without it a hung connection holds your request handler open. Download the file with a plain fetch — it needs no key, so do not send your Authorization header to the file host.

The default viewport is 1280×800. Other formats: "format": "jpeg" with "quality": 80, "webp", or "pdf".

Full page, devices, one element

The options are the same JSON fields in every language; these are the ones that change the picture:

// Whole scrollable page; lazy_load scrolls first so lazy images appear
await screenshot({ url: 'https://example.com/blog', full_page: true, lazy_load: true });

// Phone preset: viewport, pixel ratio 3, touch, mobile user agent
await screenshot({ url: 'https://example.com', device: 'iphone-15' });

// Only one element's box, at retina resolution
await screenshot({ url: 'https://example.com/pricing', selector: '#plans', scale: 2 });

// A clean page: consent banners, ads, a chat widget gone; wait for the content
await screenshot({
  url: 'https://example.com/dashboard',
  block_cookie_banners: true,
  block_ads: true,
  hide_selectors: ['#intercom-container', '.promo-bar'],
  wait_for: '.chart svg',
  delay: 1,
  dark_mode: true,
});

Device presets include iphone-15, iphone-se, pixel-8, galaxy-s24, ipad, macbook and desktop-1920; the full list is in the API docs. block_cookie_banners clicks "accept" on the common consent platforms (OneTrust, Cookiebot, Didomi and others) and hides what is left with the EasyList Cookie List. click_selector clicks a tab or a "show more" button first, up to five in order. wait_for waits for a CSS selector; delay adds whole seconds after load.

Async: queue, then poll or receive a webhook

A sync request holds the connection until the capture is done. In a serverless function with a short limit, or when you capture hundreds of pages, queue instead:

async function queue(options) {
  const res = await fetch(`${API}/screenshots`, { method: 'POST', headers, body: JSON.stringify(options) });
  const body = await res.json();
  if (res.status !== 202) throw new Error(`${res.status} ${body.error ?? body.message}`);
  return body.id; // body.url here is the status endpoint, not the image
}

async function waitFor(id, { every = 2000, tries = 30 } = {}) {
  for (let i = 0; i < tries; i++) {
    const res = await fetch(`${API}/screenshots/${id}`, { headers });
    const shot = await res.json();
    if (shot.status === 'completed') return shot;
    if (shot.status === 'failed') throw new Error(shot.error);
    await new Promise((resolve) => setTimeout(resolve, every));
  }
  throw new Error(`screenshot ${id} still running`);
}

const done = await waitFor(await queue({ url: 'https://example.com', device: 'iphone-15', full_page: true }));

Better than polling: add "webhook_url": "https://your.app/webhooks/screenshots" to the body and we call you with screenshot.completed or screenshot.failed. Each call carries X-Webhook-Timestamp and X-Webhook-Signature; the signature is the hex HMAC-SHA256 of timestamp + "." + raw body with your webhook secret (dashboard, or GET /v1/account).

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(rawBody, timestamp, signature, secret) {
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay window
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

// Raw bytes for this route only: express.json() would re-serialize the body
// and the signature would never match.
app.post('/webhooks/screenshots', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(
    req.body.toString('utf8'),
    req.get('X-Webhook-Timestamp'),
    req.get('X-Webhook-Signature'),
    process.env.SHOT_WEBHOOK_SECRET,
  );
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  if (event.event === 'screenshot.completed') {
    // event.id, event.url (the file), event.format
  } else if (event.event === 'screenshot.failed') {
    // event.error says why; the quota was returned
  }
  res.sendStatus(204);
});

timingSafeEqual throws on buffers of different length, hence the length check first. We retry a failed delivery five times with growing backoff, so answer 2xx quickly and do the slow work in a queue.

In the browser: a signed /s/ URL, never the key

Front-end code that needs a screenshot — a link preview, a thumbnail grid — should get a URL, not an API call. Sign it on the server (a route handler, a server component, a build script) with your signing secret and account id:

import { createHmac } from 'node:crypto';

// encodeURIComponent leaves ! ' ( ) * alone; the signature needs RFC 3986.
const enc = (v) => encodeURIComponent(String(v))
  .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

export function signedScreenshotUrl(pageUrl, options = {}, ext = 'png') {
  const params = { url: pageUrl };
  for (const [name, value] of Object.entries(options)) {
    params[name] = Array.isArray(value) ? value.join(',') : String(value);
  }
  const path = `/s/${process.env.SHOT_ACCOUNT_ID}.${ext}`;
  const query = Object.keys(params).sort()
    .map((name) => `${enc(name)}=${enc(params[name])}`)
    .join('&');
  const s = createHmac('sha256', process.env.SHOT_SIGNING_SECRET)
    .update(`${path}?${query}`).digest('hex').slice(0, 32);
  return `https://shareglint.com${path}?${query}&s=${s}`;
}

// <img src={signedScreenshotUrl('https://example.com', { device: 'mobile', block_cookie_banners: true })}>

The canonical string is the path, ?, and every parameter except s, sorted by name and RFC 3986-encoded; s is the first 32 hex characters of the HMAC. We checked this function against the server's own verifier: a valid URL redirects (302), a changed option returns 403. Lists such as hide_selectors become one comma-separated value, and only one click_selector fits in a URL.

The first request captures the page and charges like a POST; repeats within 24 hours redirect to the cached file. A leaked signing secret gives no API access, but it lets someone screenshot on your quota — keep it out of client bundles (in Next.js, no NEXT_PUBLIC_ prefix).

Errors, quota and retries

Status Meaning What to do
402 quota_exceeded No renders left this month Top up or upgrade; nothing was charged
422 Invalid option or a non-public url (message, errors) Fix the request; do not retry
429 Rate limit Wait for Retry-After
500 (with ?wait=true) The page failed: 4xx/5xx, timeout, element not found Read error; the quota came back
503 sync_overloaded Did not finish in the sync window Poll the id in the body

A viewport or element capture uses 1 render, a full page 2. Send an Idempotency-Key header when you retry a POST after a network error: the same key with the same body returns the first response instead of a second capture. Plans and prices are on the pricing page.

JavaScript pitfalls

  • Node 16 and older have no global fetch. Upgrade, or the examples fail with fetch is not defined.
  • Booleans in signed URLs are strings. String(true) gives "true", which is accepted; do not drop false values to "".
  • Serverless timeouts. A function that dies before the sync window ends loses the response, but the capture still runs and charges. Queue with a webhook instead.
  • Private URLs (localhost, 10.x, an intranet name) return 422 by design: the browser can only reach public addresses on ports 80 and 443.

If you would rather run the browser yourself, the Puppeteer guide shows the same captures with your own Chrome, and the screenshot API page has the price comparison.

FAQ

Can I call the screenshot API from browser JavaScript?

Not with your API key: anything shipped to the browser is public. Call the API from Node (a server route, a serverless function, a build step), or build a signed /s/ URL on the server and use it as the src of an img tag. The signature allows exactly that page and those options, nothing else.

Do I need an npm package to take screenshots from Node.js?

No. Node 18 and later ship fetch, and node:crypto covers webhook verification and URL signing. Every example on this page uses only built-in modules.

Why does my webhook signature never match in Express?

Because express.json() has already parsed the body and you are hashing a re-serialized object. Mount express.raw({ type: 'application/json' }) on the webhook route and compute the HMAC over the raw bytes, prefixed with the X-Webhook-Timestamp value and a dot.

How much does a screenshot cost?

A viewport or element screenshot uses 1 render of the monthly quota, a full-page one 2. The same URL with the same options within 24 hours returns the existing file and uses nothing, and a page that fails to load gives the render back.