# 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 2026-11-04 · https://shareglint.com/en/screenshot-api/javascript

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

```js
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()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) 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:

```js
// 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](https://shareglint.com/en/docs#screenshots). `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:

```js
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`).

```js
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`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) 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`:

```js
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](https://shareglint.com/en/pricing).

## 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](https://shareglint.com/en/guides/puppeteer-screenshot) shows the same captures with your own Chrome, and the [screenshot API page](https://shareglint.com/en/screenshot-api) 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.
