snaplab
v0.1.0
Published
Zero-dependency TypeScript client for the snaplab rendering API — screenshots, PDFs and OG images from one call.
Maintainers
Readme
snaplab
Zero-dependency TypeScript client for the snaplab rendering API — turn any URL or HTML string into a screenshot, a PDF or a 1200x630 OG image with one call.
No dependencies, ESM + CJS, works on Node 18+, Bun, Deno, Cloudflare Workers and in the browser. Full option types are shipped with the package.
npm install snaplabGet a key at snaplab.dev/signup — the free tier is 100 renders a month, no card.
import { Snaplab } from "snaplab";
const snaplab = new Snaplab({ apiKey: process.env.SNAPLAB_API_KEY! });Screenshot to a file
screenshot() resolves to a Uint8Array of the rendered bytes.
import { writeFile } from "node:fs/promises";
import { Snaplab } from "snaplab";
const snaplab = new Snaplab({ apiKey: process.env.SNAPLAB_API_KEY! });
const png = await snaplab.screenshot({
url: "https://example.com",
fullPage: true,
blockAds: true,
darkMode: true,
width: 1440,
deviceScaleFactor: 2,
});
await writeFile("example.png", png);Pass responseType: "json" instead and snaplab stores the render for you, answering with a hosted URL — the return type narrows automatically:
const shot = await snaplab.screenshot({
url: "https://example.com",
responseType: "json",
});
console.log(shot.url); // https://snaplab.dev/i/c/V1StGXR8_Z5jPDF from HTML
const pdf = await snaplab.pdf({
html: `<h1>Invoice #1042</h1><p>Due 30 September.</p>`,
css: `body { font: 14px/1.6 system-ui; padding: 40px }`,
pdf: {
format: "A4",
printBackground: true,
margin: { top: "0.6in", right: "0.5in", bottom: "0.6in", left: "0.5in" },
},
});
await writeFile("invoice.pdf", pdf);pdf() is screenshot() with format: "pdf" already set — every other render option still applies, so you can render a live url to PDF the same way.
OG image from a template
image() renders on a fixed canvas (1200x630 @2x by default). Use template with {{ placeholders }} and data; values are HTML-escaped for you.
const card = await snaplab.image({
template: `
<div class="card">
<span class="kicker">{{ section }}</span>
<h1>{{ title }}</h1>
<p>{{ author }}</p>
</div>`,
data: {
section: "Engineering",
title: "Rendering the web, on purpose",
author: "Harpreet Singh",
},
css: `
body { margin: 0 }
.card { width: 1200px; height: 630px; box-sizing: border-box; padding: 72px;
background: #0B0C10; color: #F4F6F8; font-family: "Hanken Grotesk", system-ui;
display: flex; flex-direction: column; justify-content: center }
.kicker { font: 600 20px "JetBrains Mono", monospace; color: #D8FF1A;
letter-spacing: .12em; text-transform: uppercase }
h1 { font-size: 72px; line-height: 1.05; margin: 20px 0 0 }
p { font-size: 26px; color: #9AA3AD }`,
googleFonts: "Hanken Grotesk,JetBrains Mono",
});
await writeFile("og.png", card);Signed <img> URL — no key in your HTML
Snaplab.signUrl() builds an HMAC-signed GET /api/render link you can drop straight into <img src> or a og:image meta tag. It carries the key's id and a signature, never the key itself. Sign on the server; ship the URL.
import { Snaplab } from "snaplab";
const src = await Snaplab.signUrl({
keyId: process.env.SNAPLAB_KEY_ID!, // the key's id, from the dashboard
signingSecret: process.env.SNAPLAB_SIGNING_SECRET!, // account secret, also from GET /api/me
params: {
url: "https://example.com/pricing",
width: 1200,
height: 630,
fullPage: false,
blockAds: true,
},
expiresInSeconds: 60 * 60 * 24, // optional
});
// <img src={src} width="1200" height="630" alt="Pricing" />The same call signs /api/render/image — pass path: "/api/render/image".
Webhook instead of polling
Give capture() a webhookUrl and snaplab POSTs the finished capture to it — no
waitForCapture loop. Add a webhookSecret and every callback is signed, so your
handler can prove it came from snaplab.
await snaplab.capture({
url: "https://example.com/invoice/1024",
format: "pdf",
webhookUrl: "https://api.example.com/hooks/snaplab",
webhookSecret: process.env.SNAPLAB_WEBHOOK_SECRET!,
});The callback body:
{
"event": "capture.completed",
"capture": {
"id": "Hk2Lp8qRn3vK",
"url": "https://example.com/invoice/1024",
"format": "pdf",
"status": "completed",
"width": 1440,
"height": 900,
"bytes": 34371,
"durationMs": 832,
"cacheHit": false,
"error": null,
"createdAt": "2026-05-07T18:00:00.000Z",
"completedAt": "2026-05-07T18:00:00.832Z"
},
"hostedUrl": "https://snaplab.dev/i/c/Hk2Lp8qRn3vK"
}Verify it against the raw body — parsing and re-serialising changes the bytes:
import express from "express";
import { verifyWebhookSignature } from "snaplab";
app.post(
"/hooks/snaplab",
express.raw({ type: "application/json" }),
async (req, res) => {
const ok = await verifyWebhookSignature({
secret: process.env.SNAPLAB_WEBHOOK_SECRET!,
timestamp: req.header("x-snaplab-timestamp")!,
signature: req.header("x-snaplab-signature")!,
body: req.body.toString("utf8"),
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
if (event.event === "capture.completed") await store(event.hostedUrl);
res.sendStatus(200); // anything outside 2xx is retried
},
);snaplab retries a non-2xx (or a redirect, or a timeout) after 30 s, 5 min and
30 min, then gives up. Answer 2xx as soon as you have the payload and do the work
afterwards. webhookUrl must be https:// and must not resolve to a private
address; set an account-wide default with PATCH /api/me.
API
| Method | Endpoint | Returns |
| --- | --- | --- |
| screenshot(options) | POST /api/render | Uint8Array, or HostedRender with responseType: "json" |
| pdf(options) | POST /api/render (format: "pdf") | same |
| image(options) | POST /api/render/image | same |
| capture(options) | POST /api/captures | Capture — a stored render, kept until you delete it |
| waitForCapture(id, opts?) | GET /api/captures/{id} | Capture, polled until completed or failed |
| usage() | GET /api/me | Account — plan, month-to-date usage, signing secret |
| Snaplab.signUrl(opts) (static) | — | a signed URL string |
| verifyWebhookSignature(opts) | — | boolean — checks x-snaplab-signature against the raw body |
new Snaplab({
apiKey: "sk_…",
baseUrl: "https://snaplab.dev", // optional
fetch: myFetch, // optional
});Errors
Anything non-2xx throws a SnaplabError carrying status, code and the parsed body.
import { Snaplab, SnaplabError } from "snaplab";
try {
await snaplab.screenshot({ url: "https://example.com" });
} catch (err) {
if (err instanceof SnaplabError && err.code === "quota_exceeded") {
// 429 — hard stop, snaplab never bills overage
}
throw err;
}429 (rate limit) and 503 (at capacity) are the ones worth retrying; both send retry-after.
Full API reference, every render option and the signing algorithm: snaplab.dev/docs
MIT © snaplab
