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
| 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. |
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. |
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-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 " + 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.
| 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 and a next_step. |
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 | 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.
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 / 12,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 and failed navigations are all free.
- 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.