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

FieldTypeDefaultNotes
urlstring, 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.
widthinteger 16–40001280Viewport width in CSS pixels.
heightinteger 16–4000800Viewport height in CSS pixels.
tilebooleanfalse 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_pagebooleanfalse 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_scalenumber 1–31Device 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.
delayinteger 0–150000Extra milliseconds to wait after navigation, for animations or late widgets.
timeoutinteger 1000–6000030000Navigation timeout in milliseconds. Exceeding it returns 504.
wait_for_selectorstring—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_footerstring (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_formsbooleanfalse 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_onlybooleanfalse 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.

HeaderMeaning
Content-Typeapplication/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-ResetISO timestamp when the allowance resets.
X-CacheHIT (served from cache, not billed) or MISS.
X-Page-HeightMeasured document height in CSS px. Sent on full_page renders.
X-Full-Page-Truncated1 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-OverlapSent 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-SkippedSent 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-FallbackSent 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-RemainingPer-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 pageIn the PDF
input — text, email, tel, date, password, numberText field (password renders masked; maxlength is enforced)
textareaMultiline text field
input type="checkbox"Checkbox
input type="radio" sharing a nameRadio group — one choice across the group, like on the page
selectDropdown
Pre-filled values, readonly, checked stateCarried 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 declaresRendered 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.

StatusCodeWhat it means
400BAD_REQUESTBody failed validation — unknown field, wrong type, out-of-range value, malformed JSON.
400BAD_URLUnparseable URL, or a scheme other than http/https.
401UNAUTHORIZEDMissing, unknown or deactivated API key.
402PAYMENT_REQUIREDAllowance 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.
413BAD_REQUESTRequest body over 1 MB. Rejected before parsing — see /limits.
429RATE_LIMITEDToo many requests per second. Honour Retry-After.
502BAD_STATUSThe page you asked for answered with HTTP 400 or above.
502NAV_FAILED / RENDER_FAILEDDNS, TLS or connection failure, or Chromium could not produce the file.
504NAV_TIMEOUTNavigation exceeded timeout. Raise it, or relax wait_until.
504SELECTOR_TIMEOUTwait_for_selector never became visible within timeout. Check the selector against the live page, or raise timeout.
504SCREENSHOT_TIMEOUTThe 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.
500INTERNALOur 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

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.