To take a website screenshot from JavaScript, send a POST to https://shareglint.com/v1/screenshots?wait=true from Node 18 or later with the built-in fetch, an Authorization: Bearer sk_live_… header and a JSON body such as {"url": "https://example.com", "full_page": true}. The response is JSON with a url pointing at the finished PNG, JPEG, WebP or PDF. In the browser, never use the API key: build a signed /s/ URL on the server and put it in an <img src>.
Everything below runs on plain Node — no SDK to install. The calls were tested against the API with Node 20 and Express 5.
One screenshot with fetch
const API = 'https://shareglint.com/v1';
const headers = {
Authorization: `Bearer ${process.env.SHOT_API_KEY}`,
'Content-Type': 'application/json',
};
export async function screenshot(options) {
const res = await fetch(`${API}/screenshots?wait=true`, {
method: 'POST',
headers,
body: JSON.stringify(options),
signal: AbortSignal.timeout(60_000), // fetch has no overall timeout of its own
});
const body = await res.json();
if (res.status === 503 && body.id) return waitFor(body.id); // sync window ran out: poll instead
if (!res.ok) throw new Error(`${res.status} ${body.error ?? ''} ${body.message ?? ''}`.trim());
return body; // { id, kind: 'screenshot', status: 'completed', url, ... }
}
const shot = await screenshot({ url: 'https://example.com', viewport_width: 1200, viewport_height: 630 });
await writeFile('example.png', Buffer.from(await (await fetch(shot.url)).arrayBuffer()));
(writeFile comes from node:fs/promises; waitFor is defined in the polling section.) AbortSignal.timeout() matters: without it a hung connection holds your request handler open. Download the file with a plain fetch — it needs no key, so do not send your Authorization header to the file host.
The default viewport is 1280×800. Other formats: "format": "jpeg" with "quality": 80, "webp", or "pdf".
Full page, devices, one element
The options are the same JSON fields in every language; these are the ones that change the picture:
// Whole scrollable page; lazy_load scrolls first so lazy images appear
await screenshot({ url: 'https://example.com/blog', full_page: true, lazy_load: true });
// Phone preset: viewport, pixel ratio 3, touch, mobile user agent
await screenshot({ url: 'https://example.com', device: 'iphone-15' });
// Only one element's box, at retina resolution
await screenshot({ url: 'https://example.com/pricing', selector: '#plans', scale: 2 });
// A clean page: consent banners, ads, a chat widget gone; wait for the content
await screenshot({
url: 'https://example.com/dashboard',
block_cookie_banners: true,
block_ads: true,
hide_selectors: ['#intercom-container', '.promo-bar'],
wait_for: '.chart svg',
delay: 1,
dark_mode: true,
});
Device presets include iphone-15, iphone-se, pixel-8, galaxy-s24, ipad, macbook and desktop-1920; the full list is in the API docs. block_cookie_banners clicks "accept" on the common consent platforms (OneTrust, Cookiebot, Didomi and others) and hides what is left with the EasyList Cookie List. click_selector clicks a tab or a "show more" button first, up to five in order. wait_for waits for a CSS selector; delay adds whole seconds after load.
Async: queue, then poll or receive a webhook
A sync request holds the connection until the capture is done. In a serverless function with a short limit, or when you capture hundreds of pages, queue instead:
async function queue(options) {
const res = await fetch(`${API}/screenshots`, { method: 'POST', headers, body: JSON.stringify(options) });
const body = await res.json();
if (res.status !== 202) throw new Error(`${res.status} ${body.error ?? body.message}`);
return body.id; // body.url here is the status endpoint, not the image
}
async function waitFor(id, { every = 2000, tries = 30 } = {}) {
for (let i = 0; i < tries; i++) {
const res = await fetch(`${API}/screenshots/${id}`, { headers });
const shot = await res.json();
if (shot.status === 'completed') return shot;
if (shot.status === 'failed') throw new Error(shot.error);
await new Promise((resolve) => setTimeout(resolve, every));
}
throw new Error(`screenshot ${id} still running`);
}
const done = await waitFor(await queue({ url: 'https://example.com', device: 'iphone-15', full_page: true }));
Better than polling: add "webhook_url": "https://your.app/webhooks/screenshots" to the body and we call you with screenshot.completed or screenshot.failed. Each call carries X-Webhook-Timestamp and X-Webhook-Signature; the signature is the hex HMAC-SHA256 of timestamp + "." + raw body with your webhook secret (dashboard, or GET /v1/account).
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(rawBody, timestamp, signature, secret) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay window
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(String(signature));
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
// Raw bytes for this route only: express.json() would re-serialize the body
// and the signature would never match.
app.post('/webhooks/screenshots', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook(
req.body.toString('utf8'),
req.get('X-Webhook-Timestamp'),
req.get('X-Webhook-Signature'),
process.env.SHOT_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (event.event === 'screenshot.completed') {
// event.id, event.url (the file), event.format
} else if (event.event === 'screenshot.failed') {
// event.error says why; the quota was returned
}
res.sendStatus(204);
});
timingSafeEqual throws on buffers of different length, hence the length check first. We retry a failed delivery five times with growing backoff, so answer 2xx quickly and do the slow work in a queue.
In the browser: a signed /s/ URL, never the key
Front-end code that needs a screenshot — a link preview, a thumbnail grid — should get a URL, not an API call. Sign it on the server (a route handler, a server component, a build script) with your signing secret and account id:
import { createHmac } from 'node:crypto';
// encodeURIComponent leaves ! ' ( ) * alone; the signature needs RFC 3986.
const enc = (v) => encodeURIComponent(String(v))
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
export function signedScreenshotUrl(pageUrl, options = {}, ext = 'png') {
const params = { url: pageUrl };
for (const [name, value] of Object.entries(options)) {
params[name] = Array.isArray(value) ? value.join(',') : String(value);
}
const path = `/s/${process.env.SHOT_ACCOUNT_ID}.${ext}`;
const query = Object.keys(params).sort()
.map((name) => `${enc(name)}=${enc(params[name])}`)
.join('&');
const s = createHmac('sha256', process.env.SHOT_SIGNING_SECRET)
.update(`${path}?${query}`).digest('hex').slice(0, 32);
return `https://shareglint.com${path}?${query}&s=${s}`;
}
// <img src={signedScreenshotUrl('https://example.com', { device: 'mobile', block_cookie_banners: true })}>
The canonical string is the path, ?, and every parameter except s, sorted by name and RFC 3986-encoded; s is the first 32 hex characters of the HMAC. We checked this function against the server's own verifier: a valid URL redirects (302), a changed option returns 403. Lists such as hide_selectors become one comma-separated value, and only one click_selector fits in a URL.
The first request captures the page and charges like a POST; repeats within 24 hours redirect to the cached file. A leaked signing secret gives no API access, but it lets someone screenshot on your quota — keep it out of client bundles (in Next.js, no NEXT_PUBLIC_ prefix).
Errors, quota and retries
| Status | Meaning | What to do |
|---|---|---|
402 quota_exceeded |
No renders left this month | Top up or upgrade; nothing was charged |
| 422 | Invalid option or a non-public url (message, errors) |
Fix the request; do not retry |
| 429 | Rate limit | Wait for Retry-After |
500 (with ?wait=true) |
The page failed: 4xx/5xx, timeout, element not found | Read error; the quota came back |
503 sync_overloaded |
Did not finish in the sync window | Poll the id in the body |
A viewport or element capture uses 1 render, a full page 2. Send an Idempotency-Key header when you retry a POST after a network error: the same key with the same body returns the first response instead of a second capture. Plans and prices are on the pricing page.
JavaScript pitfalls
- Node 16 and older have no global
fetch. Upgrade, or the examples fail withfetch is not defined. - Booleans in signed URLs are strings.
String(true)gives"true", which is accepted; do not dropfalsevalues to"". - Serverless timeouts. A function that dies before the sync window ends loses the response, but the capture still runs and charges. Queue with a webhook instead.
- Private URLs (
localhost,10.x, an intranet name) return 422 by design: the browser can only reach public addresses on ports 80 and 443.
If you would rather run the browser yourself, the Puppeteer guide shows the same captures with your own Chrome, and the screenshot API page has the price comparison.