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
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:
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:
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'));Getting a link instead of bytes
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.
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.
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. Pairfull_page: truewithnetwork_idle: trueand adelayof 300–1000 ms. - A
selectorthat matches nothing fails the whole render with500andcapture_failed, rather than falling back to the viewport. widthalone is ignored. The viewport override only applies when bothwidthandheightare present.- Cache hits are free, repeats are not. Identical parameters plus a
cache_ttlskip the quota charge entirely; withoutcache_ttlevery 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.