# 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](#convert-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","status":"queued"\|"rendering"\|"ready"\|"failed","error"?,"attempts","bytes"?,"webhookDelivery"?,"download"}`. `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 | pdf | 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 | pdf | 0.1 to 2 |
| `pageRanges` | no | pdf | Digits, spaces, commas, and hyphens, for example `"1-3,7"` |
| `tagged` | no | pdf | Tagged (accessible) PDF |
| `displayHeaderFooter` | no | pdf | Requires `headerTemplate` or `footerTemplate` |
| `headerTemplate` | no | pdf | HTML string, up to 10000 characters |
| `footerTemplate` | no | pdf | 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` | pdf | `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 | pdf | 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 | pdf | 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 | pdf | 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 | pdf | 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 | pdf | 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](#example). This page is also available as one markdown document at [/docs.md](https://webcapture.io/docs.md).

## Guides for common jobs

Step-by-step versions of the tasks behind these endpoints: [keeping formatting intact](https://webcapture.io/guides/html-to-pdf-without-losing-formatting), [migrating off wkhtmltopdf](https://webcapture.io/wkhtmltopdf-alternatives), and [self-hosted versus hosted costs](https://webcapture.io/guides/open-source-html-to-pdf-api). The price checks measure the field directly: [the best-of comparison](https://webcapture.io/best-html-to-pdf-api) and [the free-tier table](https://webcapture.io/free-html-to-pdf-api) price per document, the [capability matrix](https://webcapture.io/compare) compares feature by feature, and all four engine head-to-heads live on the [comparisons index](https://webcapture.io/guides#comparisons). Prefer a browser to an API call? The [free URL to PDF tool](https://webcapture.io/tools/url-to-pdf) converts a page without a key. Automating the call? The [automation platform guides](https://webcapture.io/automations) carry the same endpoint into eight no-code and agent builders, and the [recipe library](https://webcapture.io/guides/recipes) holds one complete program per task, one language per page. The full list lives on the [guides index](https://webcapture.io/guides).

For platform-specific wiring, see [n8n](https://webcapture.io/automations/n8n), [Zapier](https://webcapture.io/automations/zapier), [Make](https://webcapture.io/automations/make), [Bubble](https://webcapture.io/automations/bubble), [Clay](https://webcapture.io/automations/clay), [FlowMattic](https://webcapture.io/automations/flowmattic), [Lovable](https://webcapture.io/automations/lovable), or [Base44](https://webcapture.io/automations/base44). For an async shell job, the [curl polling recipe](https://webcapture.io/guides/recipes/curl-poll-async) 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](https://webcapture.io/tools/url-to-pdf) renders public pages without one.