Skip to main content
Ssnap Docs
Guides

Node.js screenshot API

Capture a web page from Node.js with fetch, handle errors, and stream the result to disk or S3.

Taking a screenshot from Node.js without a screenshot API means shipping Puppeteer, a Chromium binary and a few hundred megabytes of container image, then keeping that browser alive and patched. Ssnap replaces that with one fetch call.

Everything below runs on built-in Node 18+ APIs. No SDK, no dependencies.

The minimal call

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

const response = await fetch('https://ssnap.cc/api/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SSNAP_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ url: 'https://example.com', format: 'png' }),
});

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

That works, and it is also the version that will quietly write a JSON error object into a file named .png. Read the next section before shipping it.

Check the status first

The body of a successful capture is the image. The body of a failed one is JSON, whatever format you asked for. Nothing in the bytes tells you which you got, so branch on the status code:

capture.mjs
if (!response.ok) {
  const { error, code } = await response.json();
  throw new Error(`ssnap ${response.status} ${code}: ${error}`);
}

code is the stable identifier; error is prose and may be reworded. Full list in API error codes.

Streaming to disk

arrayBuffer() holds the whole render in memory. For full-page captures of long documents, pipe instead:

stream.mjs
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const response = await fetch('https://ssnap.cc/api/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SSNAP_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ url: 'https://example.com', full_page: true, format: 'png' }),
});

if (!response.ok) throw new Error(await response.text());

await pipeline(Readable.fromWeb(response.body), createWriteStream('full.png'));

Pass response: 'url' and you get a signed link back, valid for 24 hours, that needs no API key. Useful when the file is going straight into an email or an <img> and you do not want it passing through your own server:

const response = await fetch('https://ssnap.cc/api/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SSNAP_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ url: 'https://example.com', response: 'url', cache_ttl: 3600 }),
});

const { url, cached } = await response.json();

The link expires. If you need the image for longer than a day, fetch it and re-host it — and remember stored captures are pruned on the schedule in Storage and retention.

Don't block a request on a render

A synchronous capture holds your Node process for as long as Chromium takes, and the render timeout is 30 seconds. Inside an HTTP handler that is an easy way to exhaust your own connection pool.

Two ways out. Either move the call into a job queue, or let Ssnap queue it for you with callback_url: the API answers 202 at once and POSTs the finished capture to your endpoint.

Queue it
await fetch('https://ssnap.cc/api/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SSNAP_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://example.com/report',
    format: 'pdf',
    network_idle: true,
    callback_url: 'https://your-app.com/hooks/ssnap',
  }),
});

The webhook is signed. Verify it before trusting the payload — see Verify webhook signatures.

Retrying without making it worse

Both throttles answer 429, and they need opposite handling: the per-minute rate limit clears in under a minute, the monthly quota does not clear until your billing period rolls over. Looping on quota_exceeded just burns your rate limit too.

backoff.mjs
export async function capture(params, attempt = 0) {
  const response = await fetch('https://ssnap.cc/api/v1/screenshot', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SSNAP_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(params),
  });

  if (response.status === 429) {
    const { code } = await response.clone().json();
    if (code === 'quota_exceeded' || attempt >= 3) return response;

    const wait = Number(response.headers.get('retry-after') ?? 5);
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    return capture(params, attempt + 1);
  }

  return response;
}

X-RateLimit-Remaining on every response tells you how close you are before you get there. See Rate limits and quotas.

Things that catch people out

  • Lazy-loaded images come back blank on full_page. Nothing scrolls the page, so intersection observers never fire. Pair full_page: true with network_idle: true and a delay of 300–1000 ms.
  • A selector that matches nothing fails the whole render with 500 and capture_failed, rather than falling back to the viewport.
  • width alone is ignored. The viewport override only applies when both width and height are present.
  • Cache hits are free, repeats are not. Identical parameters plus a cache_ttl skip the quota charge entirely; without cache_ttl every call renders and bills. See Screenshot caching.
  • Never put the key in client-side JavaScript. A browser-visible bearer token is a public token. Call from your server, or hand out signed urls.

Next