Shotkiln API documentation
The Shotkiln API has three render endpoints: /v1/screenshot, /v1/pdf and /v1/og. Each accepts GET with query parameters or POST with a JSON body, and returns the file with the matching Content-Type. Base URL: https://shotkiln.com. The machine-readable spec is at /openapi.json.
Quickstart
# 1. Create a free key (shown once)
curl -X POST https://shotkiln.com/v1/keys
# 2. Screenshot a page
curl "https://shotkiln.com/v1/screenshot?url=https://example.com&width=1440&height=900" \
-H "x-api-key: $SHOTKILN_KEY" -o shot.pngconst res = await fetch("https://shotkiln.com/v1/screenshot", {
method: "POST",
headers: { "x-api-key": process.env.SHOTKILN_KEY, "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com", width: 1440, height: 900, format: "png" }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const png = Buffer.from(await res.arrayBuffer());
console.log("quota left:", res.headers.get("x-quota-remaining"));import os, requests
r = requests.post("https://shotkiln.com/v1/screenshot",
headers={"x-api-key": os.environ["SHOTKILN_KEY"]},
json={"url": "https://example.com", "full_page": True, "format": "webp"})
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
print(r.headers["x-cache"], r.headers.get("x-quota-remaining"))Authentication
Send the key as x-api-key: shk_live_… or Authorization: Bearer shk_live_…. Test-mode keys start with shk_test_. Without a key, requests run as an anonymous demo: 3 per minute, 10 per day per network, watermarked.
You may pass api_key= in the query string for quick tests, but a key in a public page or <img src> can be copied and used against your quota. In production, call the API from your server or build step and serve the file yourself.
Screenshot
Returns image/png, image/jpeg or image/webp.
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | required | Public http(s) URL. Ports 80, 443, 8080, 8443. |
width | integer | 1280 | Viewport width, 320–3840 (capped by plan). |
height | integer | 800 | Viewport height, 200–3840 (capped by plan). |
full_page | boolean | false | Capture the whole scroll height, max 16,384 px. |
format | string | png | png, jpeg or webp. |
quality | integer | – | 1–100, jpeg and webp only. |
delay | integer | 0 | Extra wait after load in ms, max 10,000. |
device_scale_factor | number | 1 | 1–3. Use 2 for retina. |
dark_mode | boolean | false | Emulates prefers-color-scheme: dark. |
cache_ttl | integer | 3600 | Seconds, 0–2,592,000. 0 always renders fresh. |
Returns application/pdf. Raw HTML is cached per key, never shared.
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | – | Page to print. Either url or html. |
html | string | – | Up to 1 MB, POST only. External assets must be public. |
format | string | A4 | A3, A4, A5, Letter, Legal, Tabloid. |
landscape | boolean | false | |
print_background | boolean | true | Include CSS backgrounds. |
margin | string | 10mm | All sides, or margin_top, margin_right, margin_bottom, margin_left. Units px, mm, cm, in. |
delay, cache_ttl | integer | 0, 3600 | As for screenshots. |
curl -X POST https://shotkiln.com/v1/pdf -H "x-api-key: $SHOTKILN_KEY" -H "content-type: application/json" \
-d '{"html":"<h1>Invoice 42</h1>","format":"A4","margin":"15mm"}' -o invoice.pdfOG image
Returns a 1200 × 630 image/png. Without a key the card carries a small label; with a paid key it does not. To link the endpoint from an og:image tag, use a signed URL so your key stays private, or generate cards at build time and host the PNG.
| Parameter | Type | Default | Description |
|---|---|---|---|
title | string | required | Up to 200 characters. Font size adapts to length. |
subtitle | string | – | Up to 300 characters. |
template | string | default | default, split or gradient. |
theme | string | dark | light or dark. |
accent | string | #ff5a1f | Hex colour for the bar or dot. |
site | string | – | Your name or domain, up to 60 characters. |
cache_ttl | integer | 604800 | Seconds; 7 days by default. |
Signed OG URLs
A signed URL lets you put /v1/og straight into an og:image tag without exposing your API key. The URL carries your key's public id (kid) and an HMAC-SHA256 signature (sig) of its parameters, made with the key's signing secret. Changing any parameter breaks the signature, so nobody can reuse the URL to draw other cards on your quota. Renders count against the key's plan; free keys produce watermarked cards.
- Get the signing secret once:
POST /v1/keys/signing-secretwith your key returnskidandsigning_secret. Keep the secret on your server. - Build the query string with the card parameters plus
kid(and optionallyexp, a Unix time in seconds after which the URL stops working). - Sort the parameters by name, then value. Percent-encode each name and value with
encodeURIComponentrules, join them asname=valuewith&, and prefix/v1/og?. - Append
sig: the lowercase hex HMAC-SHA256 of that string.
import { createHmac } from "node:crypto";
const enc = encodeURIComponent;
function signedOg(params, kid, secret) {
const pairs = Object.entries({ ...params, kid }).map(([k, v]) => [k, String(v)]);
pairs.sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0));
const qs = pairs.map(([k, v]) => enc(k) + "=" + enc(v)).join("&");
const sig = createHmac("sha256", secret).update("/v1/og?" + qs).digest("hex");
return "https://shotkiln.com/v1/og?" + qs + "&sig=" + sig;
}import hmac, hashlib
from urllib.parse import quote
def signed_og(params, kid, secret):
enc = lambda s: quote(str(s), safe="-_.!~*'()")
pairs = sorted((k, str(v)) for k, v in {**params, "kid": kid}.items())
qs = "&".join(enc(k) + "=" + enc(v) for k, v in pairs)
sig = hmac.new(secret.encode(), ("/v1/og?" + qs).encode(), hashlib.sha256).hexdigest()
return "https://shotkiln.com/v1/og?" + qs + "&sig=" + sigTo skip the code, POST /v1/og/sign with your key and the card parameters as JSON returns the signed url. POST /v1/keys/signing-secret/rotate issues a new secret and invalidates every URL signed with the old one; rotating the API key does the same. A wrong signature returns 403 invalid_signature, an expired one 403 signature_expired. Cache hits use no quota but count toward the per-minute limit.
Limits & headers
Every render response carries these headers, so a client can back off without calling a separate usage endpoint. GET /v1/usage returns the same numbers as JSON, plus resets_at (start of next month, UTC) and billing (whether the key has a Stripe billing portal). The usage dashboard shows them for any key.
| Header | Meaning |
|---|---|
x-ratelimit-limit, -remaining, -reset | Per-minute window (cache hits included) |
x-quota-limit, -used, -remaining, -reset | Monthly render quota, resets on the 1st (UTC) |
x-cache | HIT, MISS or BYPASS (cache_ttl=0) |
x-render-ms | Browser time for this render, on misses |
x-watermark | true on anonymous and free renders |
x-target-status | HTTP status of the page you captured |
Pages may load at most 25 MB of resources and 300 requests. The render budget is 15 s anonymous, 20 s free and 30 s on paid plans.
Caching
Speed reference: median 2.5 s for uncached 1280 × 800 screenshots of 10 public sites (18 October 2026); cache hits 0.2–0.6 s.
Identical requests (same parameters, same watermark state) are served from cache for cache_ttl seconds and do not use quota. Set cache_ttl=0 for a fresh render every time. Public URLs and OG cards share a global cache; HTML you POST to /v1/pdf is cached only for your key.
Errors
Errors are JSON: {"error":{"code":"url_blocked","message":"…"}}. Failed renders are not charged.
| Code | Status | What to do |
|---|---|---|
invalid_parameter, invalid_url | 400 | Fix the request; the message names the parameter. |
url_blocked, blocked_url | 400 | Target is private, uses a refused port or is on the denylist. |
invalid_api_key | 401 | Key is malformed, revoked or rotated. |
invalid_signature, signature_expired | 403 | Signed OG URL was changed, made with an old secret, or is past its exp. |
page_too_large | 413 | The page exceeded 25 MB or 300 requests. |
rate_limited, daily_limit, monthly_quota | 429 | Wait for Retry-After seconds, or upgrade. |
navigation_failed | 502 | The target site did not load. Check it in a browser. |
browser_unavailable | 503 | Retry after Retry-After (10 s). |
timeout | 504 | Page took longer than the plan's render budget. |
MCP
Setup for specific clients is on the MCP screenshot server page. Remote MCP server over Streamable HTTP at https://shotkiln.com/mcp, with tools take_screenshot, render_pdf and generate_og_image. Images come back as MCP image content; PDFs as embedded resources.
{
"mcpServers": {
"shotkiln": {
"url": "https://shotkiln.com/mcp",
"headers": { "x-api-key": "YOUR_KEY" }
}
}
}The machine-readable spec is at /openapi.json (OpenAPI 3.1), and a short summary for LLMs at /llms.txt.