Skip to main content

Confirm

Are you sure?

Node.js · Code recipe

Take a screenshot of a website in Node.js

Save a full-page PNG of any website, in Node.js 18+ with the built-in fetch. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To take a screenshot of a website in Node.js, POST its URL to https://urlpipe.dev/screenshot with sync set to true, decode the Base64 body with Buffer.from(text, "base64") and write the bytes to a .png file. It is a full-page capture from real Chrome for 1 credit; leave sync out and the result goes to your webhook instead.

Free plan, no credit card. 1,000 credits a month.

One POST to /screenshot loads the page in real Chrome, scrolls it top to bottom so lazy images load, and captures the whole document — not just the fold. The image comes back Base64-encoded in a text/plain body, so the Node.js work is two lines: decode it with Buffer.from(text, "base64") and write the bytes.

The request below also sets page_options.block_cookie_banners, because a consent dialog over the page is the most common reason a screenshot is useless. Every other knob — viewport, device scale, JPEG or WebP, one element by selector, dark mode — goes in screenshot_options.

Setup

Before you start

Nothing to install: fetch is global from Node 18. The files end in .mjs so Node reads them as ES modules and top-level await works without a wrapper function.

Terminal
node --version   # v18 or later
export URLPIPE_API_KEY="your_api_key"

The request

Save a screenshot as a PNG file

sync: true keeps the request open until the result is ready. fetch resolves on any status, 4xx and 5xx included, so check res.ok yourself. The body is Base64 text; Buffer.from(text, "base64") turns it back into bytes.

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

const res = await fetch("https://urlpipe.dev/screenshot", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.URLPIPE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://example.com", page_options: { block_cookie_banners: true }, sync: true }),
  // fetch has no timeout of its own; a sync call can take up to 60 s.
  signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`URLpipe answered ${res.status}: ${await res.text()}`);

const png = Buffer.from(await res.text(), "base64");
await writeFile("screenshot.png", png);
console.log(`Saved screenshot.png (${png.length} bytes)`);

Run it: node screenshot.mjs

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away. setTimeout from node:timers/promises is the awaitable sleep, so the polling loop reads top to bottom.

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

const API = "https://urlpipe.dev";
const headers = {
  Authorization: `Bearer ${process.env.URLPIPE_API_KEY}`,
  "Content-Type": "application/json",
};

// No sync: the request is accepted at once and the work carries on without you.
const accepted = await fetch(`${API}/screenshot`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    url: "https://example.com",
    page_options: { block_cookie_banners: true },
    report_to: "https://your-app.com/webhooks/urlpipe",
    labels: { customer: "acme" },
  }),
});
if (!accepted.ok) throw new Error(`URLpipe answered ${accepted.status}: ${await accepted.text()}`);
const { token } = await accepted.json();
console.log(`Accepted ${token}`);

// The result is POSTed to report_to when it is ready. Polling by token is the
// other way to collect it: no endpoint needed, and a backup for the webhook.
let res;
for (let attempt = 0; attempt < 60; attempt++) {
  res = await fetch(`${API}/result/${token}`, { headers });
  if (res.status !== 202) break; // 202 means still processing
  await sleep(2000);
}
if (res.status === 202) throw new Error("Still processing after two minutes; try the token again later.");
if (res.status === 422) throw new Error(`The analysis failed: ${(await res.json()).error}`);
if (res.status === 410) throw new Error("The result is past the 30-day window; send the request again.");
if (!res.ok) throw new Error(`URLpipe answered ${res.status}: ${await res.text()}`);

const png = Buffer.from(await res.text(), "base64");
await writeFile("screenshot.png", png);
console.log(`Saved screenshot.png (${png.length} bytes)`);

Run it: node screenshot_async.mjs

Errors

Handle errors and retries

Read the status before the body: a 401 answers in plain text, so res.json() would throw. .catch(() => ({})) turns any body that is not JSON into an empty object, and the status still says what happened.

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

class URLpipeError extends Error {}

// POST a sync request and return the response, or throw URLpipeError.
async function urlpipe(path, payload, attempts = 5) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    const res = await fetch(`https://urlpipe.dev${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.URLPIPE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ ...payload, sync: true }),
      signal: AbortSignal.timeout(90_000),
    });
    if (res.ok) return res;
    if (res.status === 401) {
      throw new URLpipeError("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");
    }

    const body = await res.json().catch(() => ({}));
    const code = body.error ?? "";
    const detail = body.message ? `${code}: ${body.message}` : code;

    if (res.status === 429 && code === "rate_limited") {
      // Sending too fast: Retry-After says how long the window has left.
      await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
    } else if (res.status === 429 && code === "concurrency_limit") {
      // Every parallel slot on your plan is busy with your own requests.
      await sleep(2 ** attempt * 1000);
    } else if (res.status === 504) {
      // Still running on our side; the token collects it from GET /result/:token.
      throw new URLpipeError(`504 processing_timeout: collect it later with token ${body.token}`);
    } else {
      // 403 email_unverified, 422 (a bad parameter, or a page that would not load),
      // 429 quota_exceeded: sending the same request again gets the same answer.
      throw new URLpipeError(`${res.status}: ${detail}`);
    }
  }
  throw new URLpipeError(`429: still refused after ${attempts} attempts`);
}

let res;
try {
  res = await urlpipe("/screenshot", { url: "https://example.com", page_options: { block_cookie_banners: true } });
} catch (error) {
  console.error(`URLpipe: ${error.message}`);
  process.exit(1);
}

const png = Buffer.from(await res.text(), "base64");
await writeFile("screenshot.png", png);
console.log(`Saved screenshot.png (${png.length} bytes)`);

Run it: node screenshot_errors.mjs

Details

What to know about /screenshot

  • The default viewport is 1350 × 797 and the capture follows the page down to 16,384 px; a taller page is cut there.
  • Every response carries an X-Result-Url header: a link to the same image that needs no API key and stays valid for 30 days. Often you can store that link instead of the file.
  • PNG is the default; "format": "jpeg" or "webp" in screenshot_options makes a long page far smaller.
  • It captures images, not PDFs, and it does not record video.
  • A screenshot of the same URL with the same options is served from the cache for 7 days by default, free. Set max_age to refresh sooner.

Other languages

Take a screenshot of a website in another language

More Node.js: every Node.js recipe

FAQ

Frequently asked questions

Why is the screenshot response Base64 and not an image?
So the same body works in JSON, in a webhook payload and in an <img> data URI. Decode it with Buffer.from(text, "base64"), or skip decoding and use the X-Result-Url header, a direct link to the image.
How do I take a mobile screenshot?
Set screenshot_options.viewport_width to a phone width such as 390 and device_scale_factor to 2 or 3. The viewport can be anything from 320 to 1920 pixels wide.
Can I screenshot one element instead of the whole page?
Yes: screenshot_options takes a CSS selector and captures just that element. A selector that matches nothing is a 422, and costs nothing.
Do I need an SDK to call URLpipe from Node.js?
No package at all: Node 18 and later ship fetch, and the API is one POST per job.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.