API docs
Every request authenticates with a bearer key in the Authorization header. API keys convert URLs; the master key (env WC_MASTER_KEY) manages every key on the deployment. The render engine is Chromium 151.0.7922.34 driven by Patchright (the Playwright 1.62 API), checked 2026-09-19.
Endpoints
| Method | Path | Auth | What it does |
|---|---|---|---|
| POST | /v1/keys | master | Create an API key. Body: {"tier":"free"|"pro"|"scale","webhookUrl":"https://…"} with webhookUrl optional but a Pro and Scale feature: attaching one on a free key answers 403 with code E_WEBHOOKS_PRO. Returns 201 {"key","id","tier","webhookSecret"}, and the webhook secret appears only in this response. |
| GET | /v1/keys | master | List API keys: {"keys":[{id, prefix, tier, revoked, hasWebhook, createdAt}]} |
| DELETE | /v1/keys/:id | master | Revoke an API key |
| POST | /v1/convert | API key | Render a URL or a raw HTML document. The JSON body fields are in Convert body fields below. By default the call holds the connection and returns the finished conversion with 200. Pass ?wait=false for the asynchronous job ticket: 202 {"id","status","poll","download"}. ?wait=true is an accepted alias of the default. |
| GET | /v1/conversions/:id.pdf | API key | Download the rendered artifact. Content-type and filename match the conversion's format (PDF, PNG, JPEG, WebP, or Markdown). The URL keeps the legacy .pdf suffix. Pass ?disposition=attachment to force a download; inline is the default and any other value answers 400. |
| GET | /v1/conversions/:id | API key | Poll a conversion's status: {"id",. webhookDelivery appears once a completion webhook was attempted: {"at","ok","attempts","status","error"}. Webhooks are best-effort; this poll is the source of truth. |
| GET | /v1/usage | API key | This month's usage: {"tier","month","used","limit","remaining"} |
| POST | /mcp | API key | MCP JSON-RPC: initialize, tools/list, tools/call with render_pdf |
Convert body fields
| Field | Required | Formats | Notes |
|---|---|---|---|
url | one of url, html | all | Public http(s) URL. Send exactly one of url or html, never both |
html | one of url, html | all | Raw HTML to render directly, up to 1,048,576 characters. The document stays out of stored rows, webhooks, analytics and error messages. Without baseUrl, the receipt's sourceUrl is about:blank |
baseUrl | no | all | Raw HTML only: a public http(s) base for relative images, CSS and other assets, checked by the same SSRF admission as url. It becomes the receipt's sourceUrl. Do not combine it with a <base> element in the document; the document's own base works without this field. Required with any raw-HTML fetch.httpHeaders, fetch.cookies or fetch.auth controls |
format | no, default pdf | all | pdf, png, jpeg, webp, or markdown |
landscape | no | A4 paper, portrait by default | |
colorScheme | no, default light | all | light or dark renders the page as if the visitor prefers that scheme; site keeps the page's own choice. Only moves pages that respond to prefers-color-scheme |
scale | no | 0.1 to 2 | |
pageRanges | no | Digits, spaces, commas, and hyphens, for example "1-3,7" | |
tagged | no | Tagged (accessible) PDF | |
displayHeaderFooter | no | Requires headerTemplate or footerTemplate | |
headerTemplate | no | HTML string, up to 10000 characters | |
footerTemplate | no | HTML string, up to 10000 characters | |
sandbox | no | all | Test mode: quota-free, does not touch your monthly count. The pdf footer is forced to a visible webcapture sandbox watermark; caller headerTemplate and footerTemplate are rejected with 422. The response echoes "sandbox": true |
destination | no | all | An https URL (up to 2048 characters) for a presigned PUT: S3, R2, or any S3-compatible endpoint that signs PUT requests. After a successful render the artifact is pushed once with a PUT, and the outcome comes back as destination: {"status"} in the response and on the completion webhook: "requested" (the 202 ticket only, before the push), "uploaded", or "failed: <class>", where the class is dns-private (a private address or a host that does not resolve), non-https, http-<status>, timeout, or network. A failed push never blocks the artifact: the download URL works regardless. A slow destination adds up to ten seconds for the push itself, plus the DNS check before it; pass ?wait=false to hand the wait to a job ticket |
quality | no, default 80 | jpeg, webp | 1 to 100 |
fullPage | no, default true | png, jpeg, webp | Capture the whole scrollable page. Set false to capture only the viewport |
viewport | no | png, jpeg, webp | Object with integer width and height, 200 to 7680 each. Sets the layout size before capture; fullPage still controls capture height |
clip | no | png, jpeg, webp | Object with non-negative integer x, y, width and height (width and height at least 1); the screenshot crops to that page region. Requires fullPage, the default: on an explicit viewport-only capture the region would be trimmed to the viewport or throw, so the pairing answers 422. Mutually exclusive with cssSelector |
cssSelector | no | png, jpeg, webp | A css selector of 1 to 500 characters; the matched element is captured instead of the page. The capture waits for the element to attach and stay stable (Playwright's own 30 second default, bounded by the render budget) and scrolls it into view itself; a selector that never matches fails the render when that wait ends. Mutually exclusive with clip |
dpi | no | png, jpeg, webp | Output density from 1 to 4, applied as the browser context's device scale factor, so page and element captures come out at the requested density. PDF points are dpi-independent, so the pairing refuses |
encode | no | all | Boolean. Adds the artifact as base64 in the response's content field, next to the unchanged download url: decoding content yields the exact stored bytes, any format, markdown included. Sync responses only; the async ticket from ?wait=false ignores it, so async callers encode the downloaded file themselves. A non-boolean answers 422 |
pageSize | no, default A4 | A4 (default), A3, Letter, Legal, or A5, matched case-insensitively and stored in its canonical form. The paper size of the pdf print call; anything off the list answers 422 with the same message, and sending it with any other format answers 422 too. The /v3 shim's pdfshift body format lands on the same field and shares this exact size list | |
transparent | no | png, jpeg, webp | Boolean. true prints the screenshot without the default white background so transparent regions survive; only png and webp have an alpha channel, so true with jpeg answers 422 |
watermark | no | Text stamped centered on every page after render: a string of 1 to 120 characters after trim, drawn in Helvetica-Bold 48pt at 0.15 opacity, rotated 45 degrees, mid gray. No font, size, color, position, or image options; a non-string answers 422, and so does text the standard PDF fonts' WinAnsi encoding cannot carry (CJK, emoji) | |
metadata | no | Object with optional title, author, subject (strings up to 300 characters) and keywords (an array of up to 20 strings of up to 60 characters), written into the PDF info dictionary after render. Unknown fields answer 422 | |
encryption | no | Object with userPassword (required, 1 to 255 characters) and an optional ownerPassword: AES-256 password protection applied last, after stamping and metadata. Requires the deployment to provide qpdf via WC_QPDF_PATH; without it the render answers 503 with code E_ENCRYPTION_UNAVAILABLE. Passwords must not start with a dash | |
fetch.httpHeaders | no | all | An object of header name and value strings sent with the source fetch: at most 20 headers, names up to 128 characters (RFC token characters), values up to 1024 characters of printable ASCII. A cookie header is refused here (pass the source cookies in fetch.cookies), and so are host and content-length. These knobs change what travels with the request, never where a render may go: source-fetch options ride URLs that already passed SSRF admission, and every redirect hop re-fires the guard. Headers and basic auth apply at the browser-context level, so they ride the source page's subresource requests and redirect hops too; cookies stay pinned to the render origin |
fetch.cookies | no | all | An array of {name, value} entries (printable ASCII, at most 20) applied to the render context and pinned to the rendered URL's host: cookies are scoped by URL to the admitted origin, never by a caller-named domain. Raw HTML must provide baseUrl with cookies so the origin can be admitted and pinned. The cookie jar lives for one render only; every render opens a fresh context and nothing persists across renders |
fetch.auth | no | all | An object with username and password (up to 255 characters each) sent as basic credentials at the browser-context level, so the source URL's 401 challenge is answered on every request of the render |
phi | no | all | Boolean. Renders with zero residue: the source url is never stored, the artifact is deleted on first download, and the render is audit-noted without the url. Requires an executed BAA on the account; requests answer 403 with code E_PHI_NOT_ENABLED until it is |
css | no | pdf, png, jpeg, webp | An inline stylesheet string of 1 to 20000 characters injected into the page after load, before the capture. Inline strings only: a css url answers 422 (css must be an inline stylesheet string; url forms are not supported) because fetching a caller url would be a new SSRF surface, so pass the stylesheet inline. markdown ignores them; styling never reaches the extractor |
javascript | no | pdf, png, jpeg, webp | An inline JavaScript expression of 1 to 20000 characters evaluated in the page after load, before the capture, so pages that set state late can expose it in time. Inline strings only, same rule and 422 message as css. The script runs in the render context by design: the same trust class as the rendered page itself |
usePrint | no | pdf, png, jpeg, webp | Boolean. Emulates print media on the page before the capture, so print-keyed media queries apply the way the printed page sees them |
waitFor | no | pdf, png, jpeg, webp | A css selector of 1 to 500 characters the render waits for after load and decoration, before the capture, capped at min(settle, 10 seconds). Chromium validates the selector syntax; a selector that never appears fails the render like any page error. markdown ignores them: all four page-level knobs never reach the markdown extractor |
disableJavaScript | no | all | Boolean. Renders with JavaScript off at the browser context, so neither the page's scripts nor any injected javascript expression can run: the pairing answers 422 (javascript cannot run with disableJavaScript) instead of one half silently winning |
disableBackgrounds | no | Boolean. Prints the PDF without page backgrounds, so colors and images behind text drop out of the print the way a browser's print dialog would drop them; unset keeps backgrounds on | |
filename | no | all | Names the download file: 1 to 100 characters of letters, digits, dot, dash or underscore, echoed on the receipt and carried by the download's Content-Disposition header. Pure-dot names answer 422 because "." and ".." are path segments wherever a filename becomes one |
waitForNetwork | no | all | Boolean. Holds navigation until the network has been quiet (a networkidle window) instead of capturing at the load event, so pages that keep fetching settle before any capture. The /v3 pdfshift shim's ignore_long_polling maps onto this same wait, accepted for parity: the quiet window already ignores long polls, so there is no second switch |
lazyLoadImages | no | all | Boolean. Keeps waiting after load until the page's images finish (loaded or failed alike) before any capture, pdf print, screenshots and markdown extraction included, on a min(settle, 10 seconds) budget; a budget that runs out fails the render rather than shipping a half-loaded capture |
raiseForStatus | no | all | Boolean. A source that answers an http 400+ status fails the render with that status instead of printing the error page. The failure is terminal and never retried: the source answers the same on retry |
removeBlank | no | Boolean. Drops rendered pages with no marking operators (no text-showing, path-painting or shading marks, and no images) after the render and before stamping, so a blank separator page does not ship. An all-blank document keeps its original pages, and the count removed is never echoed |
A conversion, end to end
curl -X POST \
https://webcapture.io/v1/convert \
-H "Authorization: Bearer wc_your_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/"}'
200 {"id": "cnv_9f2c01ab", "bytes": 34121, "attempts": 1,
"remaining": 49, "sourceUrl": "https://example.com/", "sourceType": "url",
"download": "https://webcapture.io/v1/conversions/cnv_9f2c01ab.pdf"}
# Pass ?wait=false to skip the hold and poll instead:
# 202 {"id": "cnv_9f2c01ab", "status": "queued",
# "poll": "https://webcapture.io/v1/conversions/cnv_9f2c01ab",
# "download": "https://webcapture.io/v1/conversions/cnv_9f2c01ab.pdf"}
GET the download path with the same key to fetch the PDF. GET /v1/usage for the quota left this month. Poll no faster than about once a second; renders usually finish in seconds, not minutes.
Render HTML your backend already built
For invoices, reports, certificates and receipts, build the HTML from your own data and POST it directly. No public page is needed. This example also resolves /assets/logo.png against the supplied base:
curl -X POST \
https://webcapture.io/v1/convert \
-H "Authorization: Bearer wc_your_key" \
-H "Content-Type: application/json" \
-d '{"html":"<html><body><img src="/assets/logo.png"><h1>Invoice #1042</h1><p>Total due: 420 USD</p></body></html>","baseUrl":"https://cdn.example.com/"}'
Send exactly one of html or url. Every receipt carries sourceType: "url" | "html", so clients do not need to treat about:blank as a real URL. Without baseUrl, the legacy sourceUrl is about:blank; with it, relative assets and any fetch controls use that admitted public origin.
Errors and what to do
| Status | Meaning | Next step |
|---|---|---|
| 400 | The body is not valid JSON, or names a tier that does not exist | Fix the JSON, or use tier free, pro or scale |
| 401 | Key missing, wrong, or revoked | Check the Authorization header first. If the key is revoked, mint a new one on the dashboard, or with the master key when self-hosting |
| 403 | The request carried phi but the account has no executed BAA; the body carries code E_PHI_NOT_ENABLED | Ask us to execute a BAA for the account, or drop phi from the call |
| 403 | A webhookUrl was attached to a free-tier key; the body carries code E_WEBHOOKS_PRO | Upgrade to Pro or Scale to attach signed webhooks, or drop webhookUrl from the call |
| 404 | No conversion with that id belongs to this key | Use the id from the conversion response. The download path adds .pdf, the poll path does not |
| 413 | Request body over 1 MB | Send a smaller body |
| 422 | Bad URL, unsupported protocol, or the host resolves to a private address | Send a public http(s) URL. Self-hosting and need internal URLs? Set WC_ALLOW_PRIVATE_TARGETS=1 |
| 422 | Missing or conflicting sources, an invalid document or base URL, or raw-HTML fetch controls without a base. The code is one of E_SOURCE_REQUIRED, E_SOURCE_CONFLICT, E_SOURCE_INVALID, E_BASE_URL_INVALID, E_BASE_URL_CONFLICT, E_BASE_URL_REQUIRED | Send exactly one source and follow the named code; raw-HTML fetch controls all require baseUrl |
| 422 | A body field is out of range or names a key this endpoint does not take: watermark text, a metadata field or keyword, an encryption password or field | Fix the named field. The error names the field and its limit |
| 429 | Monthly quota used up. The body carries used, limit, resetDate and upgradeUrl | Wait for the month to turn over, or follow upgradeUrl to move the key to the Pro tier |
| 429 | The account already holds its tier's full set of in-flight renders (1, 3 or 10 by tier). The body carries code, feature, requiredTier and upgradeUrl | Wait for an in-flight render to finish, or upgrade from the pricing page the body links |
| 429 | Render queue is full, or too many requests from your address. The body carries retryAfterSec | Retry after that many seconds |
| 502 | The page failed to render. Bot walls are a common cause | Retry once later. Hard targets that keep refusing are out of scope by design |
| 503 | The render asked for PDF encryption but this deployment has no qpdf configured, with code E_ENCRYPTION_UNAVAILABLE | Operator action: install qpdf and point WC_QPDF_PATH at it, or drop encryption from the call |
Webhooks
Set a webhookUrl on the key: in the dashboard key form, or in the body of POST /v1/keys. Signed webhooks are a Pro and Scale feature; endpoints must be https (http is accepted only on self-hosted deployments that allow private targets). Completed conversions POST a JSON event to it:
{"version": 1,
"type": "conversion.completed",
"conversionId": "cnv_9f2c01ab",
"apiKeyId": "key_5c1d09ab2e4f6071",
"sourceUrl": "https://example.com/", "sourceType": "url",
"pdfBytes": 5327,
"completedAt": "2026-09-13T09:41:07.000Z"}
Headers: X-WebCapture-Event carries the event type, X-WebCapture-Event-Id echoes the conversion id, X-WebCapture-Attempt counts delivery attempts, and X-WebCapture-Timestamp the unix time in seconds. X-WebCapture-Signature: t=<ts>,v1=<hex> is the HMAC-SHA256 of <ts>.<body> keyed with the key's webhook secret. The secret is shown once, when the webhook is saved. Deliveries retry with exponential backoff and failed ones are re-driven a couple more times over about half an hour before being dropped, so a receiver can see the same event twice: dedupe on conversionId. Endpoints must be https, and redirects are not followed. Failed conversions send no event.
Verify the signature before acting on an event: recompute it over the raw body your endpoint received, with the secret from the dashboard.
import { createHmac, timingSafeEqual } from "node:crypto";
export function webhookVerified(rawBody, sigHeader, secret) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(sigHeader ?? "");
if (!m) return false;
const mac = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(m[2], "hex"), Buffer.from(mac, "hex"));
}
Hash the raw request body exactly as received: re-serializing the JSON changes the bytes and the MAC never matches. The X-WebCapture-Timestamp header is inside the MAC, so treat deliveries older than a few minutes as suspect.
For agents (MCP)
webcapture ships a first-party MCP endpoint, so an agent gets PDF rendering as one tool call. The connection facts, all of them:
| Fact | Value |
|---|---|
| Endpoint URL | POST https://webcapture.io/mcp |
| Transport | Stateless JSON-RPC over HTTP POST: one request, one response, no session to hold |
| Protocol version | 2025-06-18 (Streamable-HTTP compatible) |
| Auth | Authorization: Bearer <API key>. Only tools/call requires the key; initialize and tools/list answer without one |
| Tool | render_pdf: url (required), format (pdf | png | jpeg | webp | markdown), landscape (boolean), colorScheme (light | dark | site, default light), fullPage (boolean, default true), viewport (integer width/height, 200 to 7680, image formats only) |
A minimal client config for MCP clients that speak HTTP connectors (Claude Desktop, Cursor, your own agent):
{
"mcpServers": {
"webcapture": {
"url": "https://webcapture.io/mcp",
"headers": { "Authorization": "Bearer wc_your_key" }
}
}
}
Prefer the terminal? Claude Code takes the same endpoint as one command:
claude mcp add --transport http webcapture https://webcapture.io/mcp \
--header "Authorization: Bearer wc_your_key"
With the client connected, ordinary prompts do the work:
- "Turn this article into a PDF for our records"
- "Capture this dashboard in dark mode"
- "Hand me this page as Markdown for the notes pipeline"
The tool result carries the finished document as an absolute URL on this origin, so the agent can fetch it without guessing the host:
conversion cnv_9f2c01ab complete (5327 bytes).
Download: https://webcapture.io/v1/conversions/cnv_9f2c01ab.pdf
Keep the API key server-side. The key renders pages on your account, so it never belongs in the browser or in any client-side code. Call webcapture from your own backend and hand users the finished file.
Machine-readable discovery sits next to the endpoint, so an agent can read the facts instead of the docs: the A2A agent card at /.well-known/agent-card.json, the MCP server card at /.well-known/mcp/server-card.json, and the skills index at /.well-known/agent-skills/index.json.
No MCP client? The same capability is the plain REST call in the end-to-end example. This page is also available as one markdown document at /docs.md.
Guides for common jobs
Step-by-step versions of the tasks behind these endpoints: keeping formatting intact, migrating off wkhtmltopdf, and self-hosted versus hosted costs. The price checks measure the field directly: the best-of comparison and the free-tier table price per document, the capability matrix compares feature by feature, and all four engine head-to-heads live on the comparisons index. Prefer a browser to an API call? The free URL to PDF tool converts a page without a key. Automating the call? The automation platform guides carry the same endpoint into eight no-code and agent builders, and the recipe library holds one complete program per task, one language per page. The full list lives on the guides index.
For platform-specific wiring, see n8n, Zapier, Make, Bubble, Clay, FlowMattic, Lovable, or Base44. For an async shell job, the curl polling recipe shows the full ticket lifecycle.
Render your first page in seconds
Mint a key, POST a URL, and the finished document comes back in the same response; pass ?wait=false for a job ticket with poll and download URLs. The first 50 renders a month are free, and there is no credit card. Not ready for a key? The free URL to PDF tool renders public pages without one.