Urlbox

Node.js website screenshots

Take website screenshots in Node.js with Urlbox — signed render links for img tags, sync renders for scripts, and async renders with webhooks.

There are three ways to call Urlbox from Node.js. Pick the one that matches where the screenshot ends up:

  • Render link — a signed URL you put straight into an <img> or <meta> tag. The browser fetches the screenshot; your server never waits on it.
  • Sync render — POST /v1/render/sync waits for the render and returns a renderUrl. Best for scripts, cron jobs and background workers.
  • Async render — POST /v1/render/async returns a renderId immediately and tells you when the render is done, by webhook or by polling. Best for long or bulk renders.

See Sync vs Async for the trade-offs.

Install

The urlbox NPM package signs render links for you (source on GitHub). The sync and async examples below only need fetch, which is built into Node 18 and later.

npm install urlbox

Find your API key and secret in your project's settings on the dashboard. Keep the secret on the server — never ship it to the browser.

import Urlbox from "urlbox";

const urlbox = Urlbox(process.env.URLBOX_API_KEY, process.env.URLBOX_SECRET);

const imgUrl = urlbox.generateRenderLink({
  url: "github.com",
  thumb_width: 600,
  format: "jpg",
  quality: 80,
});
// https://api.urlbox.com/v1/YOUR_API_KEY/TOKEN/jpg?url=github.com&thumb_width=600&quality=80

The link is signed with your secret, so nobody can change the options and spend your credits. Render it server-side into your HTML:

<img src={imgUrl} alt="Screenshot of github.com" />

The first request renders the page, and later requests for the same link are served from the cache. See Render links for caching and refresh options.

Render synchronously

const response = await fetch("https://api.urlbox.com/v1/render/sync", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.URLBOX_SECRET}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com",
    full_page: true,
    hide_cookie_banners: true,
  }),
});

if (!response.ok) {
  throw new Error(`Render failed: ${response.status} ${await response.text()}`);
}

const { renderUrl, size } = await response.json();
console.log(renderUrl, size);

renderUrl is a temporary link that expires after 30 days. To keep renders, download the file or have Urlbox save it to your own bucket.

To save the screenshot to disk:

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

const image = await fetch(renderUrl);
await writeFile("screenshot.png", Buffer.from(await image.arrayBuffer()));

Render asynchronously

Start the render with POST /v1/render/async. It returns straight away with a renderId and a statusUrl:

const response = await fetch("https://api.urlbox.com/v1/render/async", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.URLBOX_SECRET}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com",
    full_page: true,
    // Omit webhook_url if you'd rather poll (option 2 below).
    webhook_url: "https://your-app.com/webhooks/urlbox",
  }),
});

const { renderId, statusUrl } = await response.json();

Then collect the result in one of two ways.

Option 1: receive a webhook

Urlbox posts the result to your webhook_url when the render finishes. Before you trust the payload, check the X-Urlbox-Signature header. It has the form t={timestamp},sha256={signature}, where the signature is an HMAC-SHA256 of {timestamp}.{raw request body} keyed with your project's webhook secret (not your API secret). Here it is in Express:

import crypto from "node:crypto";
import express from "express";

const app = express();

// Keep the raw body: the signature is computed over the exact bytes Urlbox sent.
app.post(
  "/webhooks/urlbox",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("x-urlbox-signature") ?? "";
    const { t, sha256 } = Object.fromEntries(
      header.split(",").map((part) => part.split("=", 2))
    );
    const expected = crypto
      .createHmac("sha256", process.env.URLBOX_WEBHOOK_SECRET)
      .update(`${t}.${req.body}`)
      .digest("hex");

    if (
      !sha256 ||
      sha256.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sha256), Buffer.from(expected))
    ) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body);
    if (event.event === "render.succeeded") {
      console.log(event.renderId, event.result.renderUrl);
    }
    res.sendStatus(200);
  }
);

The payload carries the renderId, an event such as render.succeeded, and a result with the renderUrl and size. See Webhooks for the full format.

Option 2: poll the status URL

No public endpoint to receive webhooks (a script, a CLI, a local job)? Poll statusUrl until the render finishes:

async function waitForRender(statusUrl, { intervalMs = 2000, timeoutMs = 300_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(statusUrl, {
      headers: { Authorization: `Bearer ${process.env.URLBOX_SECRET}` },
    });
    const render = await res.json();

    if (render.status === "succeeded") return render; // { renderUrl, size, ... }
    if (render.status === "failed") throw new Error(render.reason);
    if (render.status === "not-found") throw new Error(`Unknown render ${statusUrl}`);

    // "created" or "retrying": still working
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }
  throw new Error("Timed out waiting for render");
}

const { renderUrl } = await waitForRender(statusUrl);

Send your secret with each poll, so only your project can read the render's status. Most renders finish in a few seconds; long full-page renders can take longer, so keep the timeout generous.

Next steps

On this page