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

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.
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.

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-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 " + 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

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 and a next_step.
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.
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.

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.