Capture a website screenshot with Node.js.

Twenty lines, no SDK, and a file on disk at the end of them. The same request works from any runtime that can make an HTTP call; this page writes it for Node.

Then the parts real code needs: errors and retries, a JSON body, a route in an Express app, and a signed URL to put in a page.

What you need

  • Node.js 18 or newer — fetch and Buffer are built in, so there is nothing to install.
  • An API key from the dashboard, kept in an environment variable rather than in the file.
  • A free account, if you do not have one: 100 screenshots a month, no card.

Create the key on the API keys page. The plaintext key is shown once, at creation, and cannot be retrieved afterwards — store it before closing the dialog. Send it from your own server, never from browser or mobile code: a key in a client is readable by everyone who has the app.

The request

Save this as screenshot.mjs. It asks for one page at a 1280 × 800 viewport, at twice the pixel density, encoded as WebP — and writes the bytes that come back to a file.

screenshot.mjs
import { writeFile } from 'node:fs/promises';

const params = new URLSearchParams({
  url: 'https://en.wikipedia.org/wiki/Cartography',
  format: 'webp',
  quality: '80',
  viewport_width: '1280',
  viewport_height: '800',
  device_scale_factor: '2',
});

const response = await fetch(`https://api.urlshot.io/v1/screenshot?${params}`, {
  headers: { Authorization: `Bearer ${process.env.URLSHOT_API_KEY}` },
});

// Anything that is not an image is this envelope, whatever went wrong.
if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
}

await writeFile('screenshot.webp', Buffer.from(await response.arrayBuffer()));
console.log('Wrote screenshot.webp');

Run it

Bash
export URLSHOT_API_KEY=sk_your_key_here
node screenshot.mjs

One render, one credit. A repeat of the same request within the cache window you ask for costs nothing.

What comes back

On success, the image bytes themselves — no JSON wrapper, no base64, nothing to decode. This is the file that program writes, shown at the width this page has for it:

The Wikipedia article on cartography, captured by the API at 1280 pixels wide
/v1/screenshot?url=https://en.wikipedia.org/wiki/Cartography&format=webp&quality=80&viewport_width=1280&viewport_height=800&device_scale_factor=2

The response carries what you need to account for the call, on every request:

Response headers returned with a screenshot.
X-Request-IDOpaque identifier for this request. Quote it in any support conversation.
X-Urlshot-CacheHIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0.
X-Urlshot-Credits-UsedRender credits consumed by this request. A cache hit consumes zero.
X-RateLimit-LimitRenders your plan may run at once.
X-Urlshot-RendererThe renderer build that produced the image, such as v4:4572cafff9ea; on a cache hit, the build that made the cached image. For support conversations, not for branching on.
X-RateLimit-RemainingRender slots still free after this request. Absent on a cache hit, where it is unknown rather than zero.
Cache-ControlHow long a browser may reuse the image: private, max-age=<seconds> when cache_ttl is set, and on a cache hit no longer than the cached copy has left; no-store when cache_ttl is 0.

Read them from response.headers, which ignores the case of a name:

JavaScript
// After the request: what it cost, and how to refer to it.
const credits = response.headers.get('X-Urlshot-Credits-Used'); // '1', or '0' from cache
const requestId = response.headers.get('X-Request-ID');
console.log(`request ${requestId} used ${credits} credit(s)`);

Errors and retries

A failed call returns JSON instead of an image, with a stable code, a message, and the requestId to quote if you ask about it. That is what the !response.ok branch above reads, and why it reads it before writing anything to disk.

These codes are worth retrying, and each comes with a Retry-After header in seconds:

  • concurrency_limit_exceeded, 429: The workspace is already running the maximum number of concurrent renders.
  • rate_limit_exceeded, 429: The request rate limit for this API key has been exceeded.
  • service_unavailable, 503: The screenshot service is temporarily unavailable.

Every other code fails the same way however often you send it — and one that got as far as the browser, such as render_timeout, has already cost a credit. So retry on Retry-After, not on any error:

retry.mjs
import { writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';

/** Requests a screenshot, retrying only when the API says how long to wait. */
async function screenshot(params, attempts = 4) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`https://api.urlshot.io/v1/screenshot?${params}`, {
      headers: { Authorization: `Bearer ${process.env.URLSHOT_API_KEY}` },
      signal: AbortSignal.timeout(60_000),
    });
    if (response.ok) return Buffer.from(await response.arrayBuffer());

    // Only a 429 or 503 carries Retry-After, in seconds. Anything else fails
    // the same way however often it is sent.
    const retryAfter = response.headers.get('Retry-After');
    const { error } = await response.json();
    if (retryAfter === null || attempt === attempts) {
      throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
    }
    await sleep(Number(retryAfter) * 1000);
  }
}

const params = new URLSearchParams({
  url: 'https://en.wikipedia.org/wiki/Cartography',
  format: 'webp',
});

await writeFile('screenshot.webp', await screenshot(params));
console.log('Wrote screenshot.webp');

AbortSignal.timeout gives up on a request that hangs. The codes, their statuses and their messages are all in the error reference.

More options

Every option is a query parameter, and every one has a default. Add them to the URLSearchParams as strings. These change a capture the most:

8 of the 17 options, with their ranges and defaults.
full_pageCapture the full scrollable page instead of only the viewport.boolean
formatOutput image format.png | jpeg | webp
qualityEncoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png.1–100
viewport_widthViewport width in CSS pixels.200–3840
device_scale_factorDevice pixel ratio to emulate.0.5–3
dark_modeEmulate prefers-color-scheme: dark before navigation.boolean
hide_selectorsCSS selector whose matching elements are hidden after navigation. Repeatable.string[]
block_cookie_bannersHide known consent dialogs and restore page scrolling before capturing. Available on every plan.boolean

full_page=true is the one people come for: the browser scrolls to the bottom and the capture is the whole document rather than the viewport. To hide more than one element, repeat hide_selectors — URLSearchParams would join a list into one value, so append each selector:

options.mjs
import { writeFile } from 'node:fs/promises';

const params = new URLSearchParams({
  url: 'https://en.wikipedia.org/wiki/Atlas',
  full_page: 'true',
  format: 'png',
});

// A list is the parameter repeated, once for each selector.
for (const selector of ['.vector-header-container', '.mw-footer']) {
  params.append('hide_selectors', selector);
}

const response = await fetch(`https://api.urlshot.io/v1/screenshot?${params}`, {
  headers: { Authorization: `Bearer ${process.env.URLSHOT_API_KEY}` },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
}

await writeFile('atlas.png', Buffer.from(await response.arrayBuffer()));

That is the whole Atlas article as a PNG, without Wikipedia's header and footer. All 17 options are in the parameter reference.

POST with a JSON body

The same options can go in a JSON body instead, which is easier once you send CSS: nothing to percent-encode, and every value keeps its type. Send true, not 'true', and hide_selectors as an array — a body with a string where a boolean belongs is rejected.

post.mjs
import { writeFile } from 'node:fs/promises';

const response = await fetch('https://api.urlshot.io/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.URLSHOT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  // JSON types: true rather than 'true', and a list as an array.
  body: JSON.stringify({
    url: 'https://en.wikipedia.org/wiki/Atlas',
    full_page: true,
    format: 'png',
    hide_selectors: ['.vector-header-container', '.mw-footer'],
    custom_css: '#siteNotice { display: none !important }',
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
}

await writeFile('atlas.png', Buffer.from(await response.arrayBuffer()));

Put every option in the body: a POST that also has query parameters is rejected. The rest of the rules are under POST with a JSON body.

In an Express app

In a web app, call the API from your server and send the image on. The key stays on the server, where no visitor can read it:

server.mjs
// npm install express
import express from 'express';

// The pages this app shows previews of. Visitors pick a name, never an
// address: a route that took any URL would render any page on your credits.
const pages = new Map([
  ['home', 'https://example.com'],
  ['atlas', 'https://en.wikipedia.org/wiki/Atlas'],
]);

const app = express();

app.get('/previews/:page', async (req, res) => {
  const url = pages.get(req.params.page);
  if (url === undefined) return res.sendStatus(404);

  const params = new URLSearchParams({
    url,
    viewport_width: '1280',
    format: 'webp',
    cache_ttl: '3600',
  });
  const response = await fetch(`https://api.urlshot.io/v1/screenshot?${params}`, {
    headers: { Authorization: `Bearer ${process.env.URLSHOT_API_KEY}` },
  });

  if (!response.ok) {
    const { error } = await response.json();
    console.error(`${error.code}: ${error.message} (request ${error.requestId})`);
    return res.sendStatus(502);
  }

  res.type(response.headers.get('Content-Type'));
  res.set('Cache-Control', 'public, max-age=3600');
  res.send(Buffer.from(await response.arrayBuffer()));
});

app.listen(3000, () => console.log('Listening on port 3000'));

  • Visitors choose a name, not an address. A route that rendered whatever URL it was given would let anyone spend your credits on pages of their own.
  • Repeat views are free. cache_ttl=3600 serves the same page from cache for an hour, at no cost; an hour is the longest the free plan allows. See caching and credits.
  • A failure is logged, not passed on. The route records the code and request id and answers 502: the visitor gets an error, you get something to look up.

Run node server.mjs and open /previews/home on port 3000.

Signed URLs

To put a capture straight into a page's <img>, sign the URL on your server instead. Anyone can load a signed URL, and changing any of its parameters breaks the signature:

sign.mjs
import { createHmac } from 'node:crypto';

function signedUrl(options) {
  // The key goes in the query alongside the options. Order is yours -- sign the string you are
  // about to send, and send exactly what you signed.
  const query = new URLSearchParams({ key: process.env.URLSHOT_KEY, ...options }).toString();

  const signature = createHmac('sha256', process.env.URLSHOT_SIGNING_SECRET)
    .update(query)
    .digest('hex');

  return `https://api.urlshot.io/v1/screenshot?${query}&signature=${signature}`;
}

// Safe to put in a page: altering any parameter invalidates the signature.
const src = signedUrl({ url: 'https://example.com', viewport_width: '1200' });

URLSHOT_KEY is a publishable key (pk_…) and URLSHOT_SIGNING_SECRET its signing secret, both from the dashboard. A signed URL does not expire, so add a cache_ttl to any you publish; the reference explains why under signed URLs.

Where to go next

  • The API reference — every parameter, response header and error code, generated from the contract the gateway validates against.
  • Use cases — thumbnails, monitoring, visual regression tests, link previews and AI agents, each with its own code.
  • Pricing — what a credit costs once the free allowance runs out.

The same examples in other languages.

Or see every code example.

Run it with your own key.

100 screenshots a month on the free plan. No card needed.