Snapdok API reference
One endpoint renders, one reports usage. Everything is JSON in, binary out, over HTTPS.
Base URL: https://snapdok.io
Quick start
Grab a free key (250 renders a month, no card) or buy a plan on the pricing section, copy the key you are shown, then:
export SNAPDOK_KEY=ps_live_your_key_here
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"format": "pdf"
}' \
--output example.pdf
The response body is the file. There is no job id to poll and no webhook to wire up — a render finishes inside the request.
Want the whole page, not just the first screen?
A screenshot is the viewport by default — 1280×800 unless you say otherwise, which
is the convention every screenshot API follows. Add "full_page": true to capture
everything below the fold:
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://news.ycombinator.com","format":"png","full_page":true}' \
--output fullpage.png
Rendering a form? Add "pdf_forms": true
to a PDF render and the controls come back as real, fillable AcroForm fields instead of a flat
picture — see Fillable form PDFs.
Authentication
Send your key on every call to /v1/render and /v1/usage, in either header:
Authorization: Bearer ps_live_xxxxxxxxxxxxxxxx
— or —
X-API-Key: ps_live_xxxxxxxxxxxxxxxx
Keys look like ps_live_ followed by 48 hex characters. A missing or unknown key
returns 401. Keys are stored only as SHA-256 hashes, so they cannot be recovered
from the database — keep yours in an environment variable, never in client-side code.
POST/v1/render
Renders a publicly reachable http or https URL and returns the
resulting file. Content type is application/json; unknown fields are rejected with
400.
Body parameters
| Field | Type | Default | Notes |
|---|---|---|---|
url | string, required | — | http/https only, up to 4,000 characters. |
format | "png" | "jpeg" | "pdf" | "png" |
PDF is A4 with backgrounds printed and zero margins. JPEG is encoded at quality 85. |
width | integer 16–4000 | 1280 | Viewport width in CSS pixels. |
height | integer 16–4000 | 800 | Viewport height in CSS pixels. |
tile | boolean | false |
Get the whole of an over-tall page, as numbered parts.
With full_page, a page past the 20,000 px ceiling is normally cut off.
Send tile: true and the response becomes a ZIP
(application/zip) of part-01.png, part-02.png … plus a
README: stack them top to bottom and you have the page. Consecutive parts overlap by
80 px so nothing lands in a gap and the seams are easy to align.
Up to 10 parts; past that the archive stops and still says
X-Full-Page-Truncated: 1.
Pages under the ceiling are unaffected — you get one ordinary image, as always. |
full_page | boolean | false |
Capture the whole scrollable page instead of just the visible viewport.
Off by default, so a plain request gives you one screen — pass true for the
entire page. Applies to PNG/JPEG; PDF always paginates the full document.
Captures stop at 20,000 px tall; beyond that the image is cut at the
cap and the reply carries X-Full-Page-Truncated: 1. |
device_scale | number 1–3 | 1 | Device pixel ratio — use 2 for retina output. Also accepted as device_scale_factor. |
wait_until | "load" | "domcontentloaded" | "networkidle" | "commit" |
"load" | When navigation is considered finished. Use networkidle for client-rendered apps. |
delay | integer 0–15000 | 0 | Extra milliseconds to wait after navigation, for animations or late widgets. |
timeout | integer 1000–60000 | 30000 | Navigation timeout in milliseconds. Exceeding it returns 504. |
wait_for_selector | string | — | CSS selector to wait for after navigation and delay: the render proceeds once a matching element is visible. The deterministic alternative to guessing a delay on client-rendered pages. Bounded by timeout; failure returns 504 SELECTOR_TIMEOUT and is never metered. Works for screenshots, PDFs and pdf_forms. |
pdf_header / pdf_footer | string (HTML) | — | PDF only. HTML templates printed at the top / bottom of every PDF page. Use <span class="pageNumber"></span>, totalPages, date, title and url for live values, and set an explicit font-size — the print engine's default is unreadably small. Setting one reserves a 60px margin on that edge; requests without templates keep today's zero-margin output byte for byte. Ignored when pdf_forms is on. |
pdf_forms | boolean | false |
PDF only. Turn the page's form controls into real, fillable AcroForm
fields. Open the result in Acrobat, Preview or a phone reader and type into it.
The PDF is laid out at paper width with print styles, so width is ignored on
this path. See Fillable form PDFs for what converts and what is
skipped. |
pdf_form_only | boolean | false |
Prune the PDF down to just the form. With
pdf_forms: true, drops the page's navigation, sidebars, ads and footer and
keeps the form region — fields, labels, group headings and the title above the form —
as a clean, fillable, print-ready PDF. Falls back to the full page whenever a form
region cannot be identified with confidence (reported in
X-Form-Extracted). Ignored unless format is
"pdf" and pdf_forms is true. See
Form-only PDFs. |
Response
On success: 200 with the raw file as the body.
| Header | Meaning |
|---|---|
Content-Type | application/pdf, image/png or image/jpeg |
X-Quota-Limit / X-Quota-Used / X-Quota-Remaining |
Allowance for the current billing period, after this render. |
X-Quota-Reset | ISO timestamp when the allowance resets. |
X-Cache | HIT (served from cache, not billed) or MISS. |
X-Page-Height | Measured document height in CSS px. Sent on full_page renders. |
X-Full-Page-Truncated | 1 when the page was taller than the 20,000 px cap and the capture was cut short. Absent otherwise. |
X-Tiles / X-Tile-Height / X-Tile-Overlap | Sent only on a tile response: how many parts the ZIP holds, how tall each is, and how many pixels each repeats from the part above it. |
X-Form-Fields / X-Form-Skipped | Sent only on pdf_forms renders: how many fillable fields were placed, and how many controls were skipped as unconvertible. |
X-Form-Extracted / X-Form-Extract-Fallback | Sent only on pdf_form_only renders: true when the PDF was pruned to the form region, false when it fell back to the full page — with the reason (e.g. no-main-controls) in the second header. |
X-RateLimit-Limit / X-RateLimit-Remaining | Per-second budget for your key. |
Examples
JavaScript / Node 18+
const res = await fetch("https://snapdok.io/v1/render", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.SNAPDOK_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
url: "https://example.com",
format: "pdf"
})
});
if (!res.ok) throw new Error("snapdok.io " + res.status + ": " + await res.text());
// res.body is the file itself — write it, stream it, or upload it.
const pdf = Buffer.from(await res.arrayBuffer());
console.log(res.headers.get("x-quota-remaining"), "renders left");
Python
import os, requests
r = requests.post(
"https://snapdok.io/v1/render",
headers={"Authorization": f"Bearer {os.environ['SNAPDOK_KEY']}"},
json={"url": "https://example.com", "format": "png",
"full_page": True, "width": 1440},
timeout=90,
)
r.raise_for_status()
with open("shot.png", "wb") as f:
f.write(r.content)
print(r.headers["X-Quota-Remaining"], "renders left")
Full-page retina screenshot of a client-rendered app
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.com/report/42","format":"png",
"full_page":true,"width":1440,"device_scale":2,
"wait_until":"networkidle","delay":400}' \
--output report.png
Fillable form PDFs
This is the feature people come here for. A normal URL-to-PDF render gives
you a picture of a form. Add "pdf_forms": true (with "format": "pdf")
and you get the form itself: every supported control on the page becomes a real AcroForm field,
positioned exactly where it sits in the layout, and the PDF can be filled in — in Adobe
Acrobat, macOS Preview, a browser's PDF viewer or a phone reader — then saved with the values
kept. It is a base feature of every plan at no extra charge: a pdf_forms render
is metered as one ordinary render.
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.com/intake-form","format":"pdf","pdf_forms":true}' \
--output fillable.pdf
What converts:
| On the page | In the PDF |
|---|---|
input — text, email, tel, date, password, number | Text field (password renders masked; maxlength is enforced) |
textarea | Multiline text field |
input type="checkbox" | Checkbox |
input type="radio" sharing a name | Radio group — one choice across the group, like on the page |
select | Dropdown |
Pre-filled values, readonly, checked state | Carried over (readonly becomes a read-only field) |
What does not, and how it is handled. Some controls have no PDF
equivalent or cannot be read from outside: file pickers, sliders (range), color
pickers, hidden inputs, controls that are invisible on the page, forms inside iframes, and
fake widgets built out of styled divs by JavaScript. These are skipped
cleanly — counted in the X-Form-Skipped header, never guessed at, never
dropped onto the page in the wrong spot. X-Form-Fields tells you how many fields
were placed, so you can verify a render did what you expected without opening the file.
Chinese input works. When the page contains CJK text, a Chinese font subset is embedded into the PDF automatically, so the fields accept and display Chinese values on machines with no CJK fonts installed. Rare characters outside the embedded subset fall back to the reader's own fonts.
Layout notes. The forms path lays the page out at paper width with print
styles — the same geometry an ordinary PDF render uses — so width has no effect
here, and a mobile-first page renders in its paper layout, not a phone layout. A field that
would straddle a page break is clipped to its page; PDF has no cross-page fields.
Form-only PDFs
A fillable PDF of a whole web page still carries the web page around it — the navigation
bar, the sidebar, the footer. Add "pdf_form_only": true to a
pdf_forms render and the PDF is pruned down to just the form: the
fields with their labels, the group headings, and the title or intro text sitting directly
above the form. What comes out is a clean, fillable, print-ready form — like the paper
version of the form, not a printout of the website.
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.com/intake-form","format":"pdf",
"pdf_forms":true,"pdf_form_only":true}' \
--output form-only.pdf
What it can and cannot isolate — read this before relying on it.
Extraction works on pages where a visible group of form controls sits in an identifiable
region: contact forms, sign-ups, checkouts, government/intake forms. It deliberately refuses
to guess: when the page has no visible controls, only a lone search box, a form that wraps
the entire page (common on older ASP.NET sites), forms inside iframes, or JavaScript widgets
built from styled divs, the render falls back to the full page — the
exact bytes pdf_forms alone would have produced, never a half-broken cut. The
rule it is built around: better a navigation bar too many than a required field too few.
Check X-Form-Extracted in the response: true means the PDF is
the pruned form, false means the full page, with the reason in
X-Form-Extract-Fallback. The flag is ignored unless format is
"pdf" and pdf_forms is true, and it is metered as one
ordinary render — no extra charge.
Fonts
The render machines have CJK fonts installed, so Simplified Chinese, Traditional Chinese,
Japanese and Korean render as real glyphs rather than .notdef tofu boxes
(□□□) — in PNG, JPEG and PDF alike, with no font configuration on your side.
PDF output embeds a subset of the font used, so the file reads correctly on a machine that has
no CJK font installed.
Per-language glyphs. Japanese, Korean, Simplified Chinese and Traditional
Chinese share Unicode codepoints for many Han characters but draw a number of them differently
(Han unification). We install Noto Sans CJK and Noto Serif CJK in
their JP, KR, SC and TC variants and pick between them from the language the page declares:
| Page declares | Rendered with |
|---|---|
lang="ja" | Noto Sans / Serif CJK JP |
lang="ko" | Noto Sans / Serif CJK KR |
lang="zh-CN" | Noto Sans / Serif CJK SC |
lang="zh-TW" | Noto Sans / Serif CJK TC |
The lang attribute works on the whole document or on a single element, so a
mixed-language page can get all four at once. A page that declares no language still renders
every CJK script without tofu — it just falls back to one pan-CJK face rather than picking a
regional one, so declare lang if the distinction matters to your readers.
Web fonts your page loads itself are fetched and applied as usual; if a glyph exists in neither your web font nor the installed fonts, Chromium falls back the same way it would locally.
GET/v1/usage
Authenticated. Reports where your key stands right now.
curl https://snapdok.io/v1/usage -H "Authorization: Bearer $SNAPDOK_KEY"
{
"owner": "you@example.com",
"plan": "starter",
"billing_source": "subscription",
"subscription_status": "active",
"used": 137,
"limit": 3000,
"remaining": 2863,
"can_render": true,
"period_kind": "subscription",
"period_start": "2026-08-20T14:03:11.000Z",
"period_end": "2026-09-20T14:03:11.000Z",
"reset_at": "2026-09-20T14:03:11.000Z",
"rate_per_sec": 5
}
period_kind is subscription when your allowance follows your Stripe
renewal date, rolling for a free key (30 days from signup), or
calendar for older keys with neither attached. Prefer
period_start / period_end over the legacy month field,
which is only kept for backwards compatibility.
Rather look at it than parse it? The dashboard renders the same data in a browser.
GET/health
Unauthenticated liveness probe: service status, whether the browser pool is connected, cache state and uptime. Safe to poll from your monitoring.
Errors
Every failure returns JSON shaped {"error": "CODE", "message": "..."}. A failed
render is never metered and never cached.
| Status | Code | What it means |
|---|---|---|
400 | BAD_REQUEST | Body failed validation — unknown field, wrong type, out-of-range value, malformed JSON. |
400 | BAD_URL | Unparseable URL, or a scheme other than http/https. |
401 | UNAUTHORIZED | Missing, unknown or deactivated API key. |
402 | PAYMENT_REQUIRED | Allowance exhausted, or the subscription is not active. Body includes reason, reset_at, a next_step you can call, and a help link if you would rather talk to a person. |
413 | BAD_REQUEST | Request body over 1 MB. Rejected before parsing — see /limits. |
429 | RATE_LIMITED | Too many requests per second. Honour Retry-After. |
502 | BAD_STATUS | The page you asked for answered with HTTP 400 or above. |
502 | NAV_FAILED / RENDER_FAILED | DNS, TLS or connection failure, or Chromium could not produce the file. |
504 | NAV_TIMEOUT | Navigation exceeded timeout. Raise it, or relax wait_until. |
504 | SELECTOR_TIMEOUT | wait_for_selector never became visible within timeout. Check the selector against the live page, or raise timeout. |
504 | SCREENSHOT_TIMEOUT | The page loaded but could not be rasterized in time — usually a very tall, text-dense page with full_page. Drop full_page, or narrow width and capture in sections. |
500 | INTERNAL | Our fault. Retry; if it persists, tell us. |
402 is not 429. 402 means you are out of
renders for the period; 429 means you are sending them too fast. Retrying a
402 immediately will not help — upgrade or wait for reset_at.
Rate limits
Paid keys are allowed 5 requests per second;
free keys 2 per second. It is a token bucket, so short
bursts are fine. Over the limit you get 429 plus Retry-After — that is
throughput, not quota, so retrying after the delay works. Need more
throughput for a batch job? Ask — it is a per-key setting.
The request body is capped at 1 MB. A larger body is refused with 413 before it is parsed, so nothing is
validated, rendered or metered. Render parameters are a URL plus a handful of scalars, so a real
call is nowhere near the limit — hitting it usually means a request was built wrong.
Every ceiling on one page, including timeouts and the full_page height cap:
/limits.
Caching
Requests are fingerprinted over the full parameter set. An identical request within
24 hours is served from disk with X-Cache: HIT and
X-Cache-Age-Ms, costs you nothing and is not metered. Change any parameter — even
a cache-busting query string on the URL — to force a fresh render.
Because a cache hit is free, it leaves your X-Quota-Used exactly where it was.
Every render reply therefore also carries X-Quota-Charged: 0 when the
response came from cache, 1 when we actually rendered and took a unit off your
allowance. Check that header before concluding a render went uncounted — a repeat of the same
request is meant to be free.
Quota & billing
- Free — 250 renders per 30 days, no card. The window is anchored to the day
you signed up, not to the 1st: sign up on the 28th and you still get a full 30 days.
/v1/usagereportsperiod_kind: "rolling"and the exactreset_at. - Starter / Growth / Scale — 3,000 / 15,000 / 60,000 renders per billing period. The period follows your Stripe renewal date, not the calendar month, so subscribing on the 20th means your counter resets on the 20th.
- Every plan is a hard cap. When the allowance is gone the API returns
402until the period rolls over or you upgrade. Nothing is ever charged per render on top of your plan — you cannot receive a bill larger than the plan you chose. - Only real renders are billed. Cache hits, validation errors, auth failures, rate limits, blocked URLs, target-site errors, timeouts and failed navigations are all free — if the API did not hand you a file, your counter did not move.
- There is no per-feature pricing.
pdf_formsis included in every plan at no extra charge; a form render is metered as one ordinary render. - Cancelling stops the renewal; the allowance simply stops resetting.
Key handling & lost keys
Your key is shown once — on the checkout confirmation page for a paid plan, or straight on the free signup form for the Free plan — and stays readable for about ten minutes so a refresh cannot destroy it. After that only its SHA-256 hash exists on our side — we genuinely cannot look it up for you.
Leaked it, or just rotating on schedule? Do it yourself — no ticket, no wait. Paste the key on the dashboard and use Revoke & regenerate, or call it directly:
curl -X POST https://snapdok.io/v1/keys/rotate \
-H "Authorization: Bearer ps_live_your_current_key"
The old key stops authenticating immediately and the new one works at once. Your plan, the renders you have already used, your billing period and your subscription are all untouched — only the secret changes. The new key is shown once in that response. Rotating requires a working key, deliberately: if an email address were enough, anyone who knew yours could take over your account.
Lost it completely, with nothing left to authenticate with? Send us the email address you paid with and we will verify the payment, revoke the old key and issue a replacement.
Treat the key like a password: server-side only, in an environment variable. Anyone holding it can spend your renders.