# webcapture

> webcapture is a URL to PDF API: one REST call (or one MCP tool call) renders a public web page to a

> print-stable PDF with headless Chromium (Chromium 151.0.7922.34 driven by Patchright (the Playwright 1.62 API), checked 2026-09-19). This document is the whole public

> site: API reference, pricing, guides, comparisons, automation platform guides, and recipes, in reading order.



## API reference

# 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. Testing an integration? Pass `"sandbox": true`: sandbox renders are quota-free on every plan and every format.

1. Sign in at the [dashboard](https://webcapture.io/dashboard) with Google or a one-time email code.
2. Mint one yourself. The Free tier includes 50 renders a month per account.

Self-hosting? Skip the dashboard: your master key creates, lists, and revokes keys through the endpoints below.

## 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"` |
| `settle` | no | all | Integer milliseconds from 0 to 10000: the post-load settle the render waits before its text check and capture. A JSON null (or an absent field) keeps the deployment default. This same value caps the `waitFor` selector budget and the `lazyLoadImages` image budget, both at `min(settle, 10 seconds)` |
| `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"}
```

1. GET the download path with the same key to fetch the PDF.
2. GET /v1/usage for the quota left this month.
3. 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;
  // Replay guard: the timestamp rides inside the MAC, so refusing old ones
  // costs nothing extra. Five minutes, in both clock-skew directions.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(m[1]));
  if (ageSeconds > 300) 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, and the example enforces it: a delivery stamped more than five minutes away (either direction, to absorb clock skew) fails before the MAC is compared. Pick the tolerance your receiver can afford, and dedupe on the event id, because a retry inside the window still passes.

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

## Pricing

# webcapture pricing

One render is one rendered page. Every plan includes the full API, async jobs, polling, the
MCP endpoint and all output formats (PDF, PNG, JPEG, WebP, Markdown); signed webhook delivery
and higher concurrency start at Pro, and 30-day render retention starts at Scale. Quotas reset
each calendar month.

## Free
- Price: $0/month
- Limits: 50 renders/month
- Features: full API, async jobs, polling, MCP endpoint, all output formats

## Pro
- Price: $19/month, or $190/year (billed via Stripe; annual bills ten months for twelve)
- Limits: 5,000 renders/month
- Features: everything in Free with the higher quota, signed webhook delivery, 3 concurrent renders

## Scale
- Price: $69/month, or $690/year (billed via Stripe; annual bills ten months for twelve)
- Limits: 35,000 renders/month
- Features: everything in Pro with the higher quota, 10 concurrent renders, 30-day render retention

## Features by tier
- Signed webhook delivery: Pro and Scale
- Concurrency: Free 1, Pro 3, Scale 10 concurrent renders
- Artifact retention: Free and Pro 7 days, Scale 30 days

## Notes
- No per-document overage: an exhausted quota returns HTTP 429 until the month rolls over
- Keys are self-serve: sign in at /dashboard and mint one
- Scale is the largest self-serve tier
- The human-readable pricing page lives at /pricing
- Contact: hello@webcapture.io


## Guides

### Free HTML to PDF APIs compared

# Nine free HTML to PDF API tiers, honestly compared

The nine free tiers below link the vendor's official pricing page, with its catch printed in the same row. The original review ran 2026-09-11; later corrections carry their own dates in Method. Ours is not the biggest number on the page: 50 documents a month, the full API, commercial use allowed, and when you exceed it you get a 429 rather than a bill.

## Inclusion methodology

This is a nine-row sample of hosted APIs with public self-serve HTML-to-PDF free terms and accessible official docs, not every vendor in existence. Rows are included only when those public terms can be checked without an account or a paid call.

## The free tiers

| API | Free volume | Resets | The catch | Commercial use |
| --- | --- | --- | --- | --- |
| [PDFEndpoint](https://pdfendpoint.com/pricing) | 100/month | never (permanent plan) | none stated | silent |
| [DocRaptor](https://docraptor.com/plans) | 5 documents | monthly | overage not allowed | yes |
| [pdflayer](https://pdflayer.com/pricing) | 100/month | monthly | non-commercial use only | no |
| [PDFShift](https://pdfshift.io/pricing) | 50/month | monthly | 30-second timeout on free | yes |
| [APITemplate.io](https://apitemplate.io/pricing/) | 50/month | monthly | template-first | yes |
| [PDFMonkey](https://www.pdfmonkey.io/pricing) | 300/month | monthly | 1-day retention; external images, fonts, CSS and JS blocked | silent |
| [ConvertAPI](https://www.convertapi.com/prices) | 250 conversions | one-time on signup | not a monthly tier; plan-gated concurrency runs 1, 2, 3, then Business unlimited | yes |
| [CloudConvert](https://cloudconvert.com/pricing) | 10/day | daily | general conversions, not renderer-grade | yes |
| webcapture | 50/month | monthly | quota returns 429 until reset, no paid overage | yes |

Two rows need a footnote. DocRaptor's 5 documents are real, and its plan pairs them with unlimited watermarked test documents, fine for building against but stamped so they cannot ship, and no overage is allowed on the real ones. ConvertAPI's 250 conversions are a one-time signup grant rather than a monthly tier, which the resets column says plainly.

## What free tiers do not advertise

The volume number is the headline; the fine print decides whether a tier survives contact with a real workload. Four failure modes recur across this field, and none of them show up in the number a landing page leads with.

- **Timeouts**: PDFShift's free tier renders against a 30-second clock, so one slow page burns quota without producing a file. [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift) prices the paid tiers against ours.
- **Concurrency**: ConvertAPI gates parallel renders at 1, 2, or 3 by plan, with Business and Enterprise unlimited, so a single slow document can queue work behind it.
- **Non-commercial clauses**: pdflayer's free tier is licensed for non-commercial use only, which quietly disqualifies it from any revenue product.
- **Asset blocks**: PDFMonkey's free tier refuses external images, fonts, CSS and JS, so anything beyond inline-styled text renders incomplete.

Every row above prints its catch at the same size as its volume, because on a free tier the catch is usually the term that applies first.

## About ours

webcapture is the last row, and the bias is disclosed in the row itself. The free tier is 50 documents a month, it is the full API rather than a feature-limited one, commercial use is allowed, and when you exceed it you get a 429 rather than a bill: the quota blocks until the month rolls over and no invoice follows. Where we lose on this table it says so: PDFEndpoint's 100 a month that never expires is the volume floor, and if you can run a container, self-hosting trades these quota clocks for operations you own. Our plans are published on [our pricing page](https://webcapture.io/pricing).

Going deeper: [the HTML to PDF API](https://webcapture.io/html-to-pdf-api) keeps engine, pricing, and a curl quick start on one page, [the best-of comparison](https://webcapture.io/best-html-to-pdf-api) prices the paid field per document, [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains why conversions break and how browser rendering keeps layout intact, and the [alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) maps the archived tool flag by flag. When the decision is capability rather than volume, the [capability matrix](https://webcapture.io/compare) reads the same eight vendors across fourteen rows, our own gaps printed with the vendor to choose instead. If hosted prices brought you here, [the open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) is the self-hosted side of the same table. The [HTML to PDF guides](https://webcapture.io/guides) hold the migration walk-throughs, and the [recipes hub](https://webcapture.io/guides/recipes) turns the same call into runnable programs.

## Method

Checked 2026-09-11 against each vendor's own pricing page, quoting volume, reset rule, and catch as listed. Source URLs checked 2026-10-24. PDFMonkey's volume, retention and asset restrictions, ConvertAPI's concurrency ladder, PDFEndpoint's silent commercial-use flag, the title, and the inclusion methodology were corrected on 2026-10-26. Questions or corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### Best HTML to PDF APIs compared

# The best HTML to PDF APIs, with prices

Every price below comes from the vendor's own pricing page, at or nearest the 10,000 documents a month band. Rows for Pdfcrowd, CraftMyPDF and ScreenshotOne checked 2026-10-03; the rest 2026-09-11. Engines are printed as disclosed, or as not disclosed where the vendor stays silent, and every row carries the catch the ranking roundups leave out. The short verdict: Gotenberg is the free self-hosted route; webcapture Pro costs 0.38 cents at 5,000 and Scale about 0.197 cents at 35,000. The engine and catch columns decide the rest.

## The field

| API | Engine | Price around 10k/mo | Free tier | The catch |
| --- | --- | --- | --- | --- |
| [DocRaptor](https://docraptor.com/plans) | Prince (disclosed) | 2.98 cents at 5,000 | 5 docs + unlimited watermarked tests | about eight times the cheapest measured rate, and 12 cents a document at the smaller bands |
| [PDFShift](https://pdfshift.io/pricing) | Chromium (disclosed) | 0.396 cents at 25,000 | 50/month | free tier 30-second timeout |
| [PDFEndpoint](https://pdfendpoint.com/pricing) | not disclosed | 0.39 cents at 10,000 | 100/month, permanent | engine not stated on their pages |
| [pdflayer](https://pdflayer.com/pricing) | not disclosed | 0.40 cents at 10,000 | 100/month | free tier is non-commercial only |
| [APITemplate.io](https://apitemplate.io/pricing/) | Chromium (disclosed) | 0.63 cents at 3,000 on annual billing | 50/month | 0.63 cents at 3,000 on annual billing; monthly is $24/3,000; credits and the template model shape the bill |
| [Pdfcrowd](https://pdfcrowd.com/pricing/) | not disclosed | 0.61 cents at 10,000 | free trial | credits meter output size: one credit per 0.5Mb of rendered output |
| [CraftMyPDF](https://craftmypdf.com/pricing/) | not disclosed | 0.83 cents at 12,000 | 50/month + 3 templates | one shared credit ledger: e-signature and watermark spend the same credits |
| [ScreenshotOne](https://screenshotone.com/pricing/) | not disclosed | 0.79 cents at 10,000 screenshots | 100 screenshots | fresh screenshots and PDFs spend credits, cached screenshots do not; PDF uses its separate pdf_* option set |
| [PDF.co](https://www.pdf.co/pricing) | not disclosed | credit pool, per-doc rate unpublished | trial only | credits-per-conversion not published |
| [ConvertAPI](https://www.convertapi.com/prices) | not disclosed | unit price not rendered on their pricing page | 250 sign-up conversions | concurrency is plan-gated: 1, 2, 3, then Business unlimited |
| [Api2Pdf](https://www.api2pdf.com/pricing) | Chrome + wkhtmltopdf + LibreOffice (disclosed) | $1/month + $0.001/MB + $0.00019551/sec | unverified | usage-metered bill; free credit terms unverified |
| [Gotenberg](https://gotenberg.dev/docs/getting-started/introduction) | Chromium (disclosed) | free, self-hosted | n/a | you own the container ops |
| webcapture | Chromium (disclosed) | 0.38 cents at 5,000 | 50/month, full API | that is us: we publish this table |

## The price per document, side by side

The table prints each rate with its band; the bars strip the band away and line up what one document costs at each vendor's own published band. The longest bar is DocRaptor's, the field's most expensive measured rate, and the spread across the field runs to a factor of eight. Vendors whose billable unit is not a published number per document, PDF.co, ConvertAPI and Api2Pdf, have no bar here at all, which is itself the honest reading of their pricing pages.

One rendered document of any size is this service's billable unit; vendors that meter output size or browser time move with the document, the bars are their published entry numbers.

- DocRaptor ** $0.0298 a document at 5,000 a month
- PDFShift ** $0.00396 a credit one credit per 5Mb of output
- PDFEndpoint ** $0.0039 a document at 10,000 a month
- pdflayer ** $0.004 a document at 10,000 a month
- APITemplate.io ** $0.0063 a document at 3,000 a month
- Pdfcrowd ** $0.0061 a credit one credit per 0.5Mb of rendered output
- CraftMyPDF ** $0.0083 a document at 12,000 a month
- ScreenshotOne ** $0.0079 a screenshot billed per screenshot; PDF is a side endpoint
- Gotenberg ** $0 a document free and self-hosted: you own the container ops
- webcapture ** $0.0038 a document one render per document, any size

Read the unit under each price with the bar. DocRaptor's is the long one because 2.98 cents at 5,000 is about eight times the cheapest measured rate; webcapture's is short because a render bills as one document however heavy the page is; PDFShift and Pdfcrowd meter output size, so a heavy document buys more credits than the bar alone shows; and Gotenberg's zero is the price of running your own container, not of a hosted service.

## How to choose

Three questions decide it, in order. First, the volume band: below a few hundred documents a month a free tier wins outright, and from the thousands upward the per-document rate at your band is the number that matters; across this field it spans a factor of eight. Second, the engine requirement: if the output has to match a browser page exactly, only the disclosed-Chromium rows qualify, because a "not disclosed" engine is a gamble taken with your layout. Third, ops appetite: if you can run a container and page yourself, the free self-hosted row wins on price; if you cannot, you are choosing among the hosted rows on rate, free tier, and candor.

## What the roundups omit

Search for this topic and most top-ten results are lists with no prices, no engines, and no catches, ranked toward whoever pays the affiliate cut. The catch column above is that content gap, named: each row states the one thing the vendor's own page buries, from the 30-second timeout on PDFShift's free tier to DocRaptor's 2.98 cents a document at 5,000. PDFShift's credit counts one per 5Mb of output, so a 14Mb document costs 3 (re-checked 2026-10-03). Its opt-in Boost overage lists at three cents a credit, 3.1 times the in-plan rate (re-checked 2026-10-24). A roundup without a catch column is an ad. Leaning PDFShift? [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift) prices the two contracts side by side.

## Vendor by vendor

The capability matrix holds eight vendors a buyer actually shortlists on fourteen rows; this section walks the seven beside us one at a time. Each paragraph states what the vendor is, the row it wins on that matrix, what the bill looks like at its published band, and the honest case for choosing it. Every fact comes from the same two sources the rest of the page cites: the vendor's own documentation and its own pricing page.

[PDFShift](https://pdfshift.io/pricing) is a hosted rendering API whose docs disclose Chromium, and the compliance row is the one it wins outright: an is_hipaa switch backed by a BAA, where our own phi mode answers 403 until an executed BAA exists on the account and several shortlist rows print nothing at all. The rest of its cell set reads broad: the full wait-control row, caller css and javascript, watermark, metadata and encryption, and /templates CRUD for stored templates, which our per-request headers and footers do not have. The bill is credit-shaped: one credit per 5Mb of output, 0.396 cents a credit at 25,000, so a 14Mb document costs 3. Async needs a webhook; the spec has no polling endpoint. Choose them when output-size metering fits your documents and the HIPAA paperwork matters today.

[DocRaptor](https://docraptor.com/plans) is the Prince-powered document API, and it says so in the engine cell: Prince, disclosed, and not a browser engine, so client-rendered widgets are not what it prints. The certification row is its cleanest: HIPAA, SOC 2 and GDPR documented, beside sync calls, a status URL for async, and webhooks. The pricing shape is the page's most expensive row, 2.98 cents a document at 5,000, about eight times the cheapest measured rate and 12 cents at the smaller bands; the free side answers with 5 documents a month plus unlimited watermarked test documents. Caller css and script injection are absent, and there is no storage export. Choose them when a documented compliance posture outweighs the per-document rate.

[Gotenberg](https://gotenberg.dev/docs) is the field's honest zero: a free, open source container you run yourself, Chromium for HTML and LibreOffice for office documents. It is the only vendor on the self-hosting row, and the bill is your own container ops rather than a rate card, which is the catch its own row prints. The matrix still reads wide for a free tool: webhooks, waitDelay and waitForExpression, and watermark, encryption and metadata post-processing. What it does not carry is anything hosted: no sandbox, no storage export, no caller css or script injection; the bytes come straight back when the render finishes and nothing is stored. Choose them when you can run and page a container and want the price at zero.

[Urlbox](https://urlbox.com/pricing) is a screenshot and rendering service whose engine cell carries a per-request engine_version pin of stable, latest or experimental, a control Browserless's cell names as absent, so a rerun can hold the same Chromium the last run used. Its output row is one of the widest on markdown, format=md, beside pdf, png, jpeg and webp, and storage is first-class with use_s3 and presigned url export. Pricing is double-metered: from $49 for 5,000 renders, about 0.98 cents each, and a render is also spent per 30 seconds of render time and per 5Mb of output. There is no free plan, by explicit policy, and its sandbox counts against the monthly quota. Choose them when a pinned engine version and markdown output decide the row.

[Browserless](https://browserless.io/pricing) is a browser infrastructure API rather than a document API: what it sells is blocks of browser time, $25 for 20,000 units with one unit up to 30 seconds, not renders, and that row is the one it wins. A long interactive session or a crawling workload is exactly what unit pricing serves, and the free tier hands over 1,000 units with sessions capped at two minutes. The engine is named by the endpoint path, Chrome, Chromium or Edge, with no per-request version pin, and the wait-control and injection rows read complete: waitForSelector, waitForFunction, waitForTimeout, addStyleTag, addScriptTag. Post-processing and storage export are absent, and the HIPAA story lives on a pricing-page band rather than in the API spec. Choose them when you are driving a browser, not printing a document.

[ScreenshotOne](https://screenshotone.com/pricing/) is a screenshot API first, and the image-format row is the one it wins: gif, jp2, tiff, avif and heif carried beside the pdf, png, jpeg, webp and markdown the wide rows have, formats nothing else on the matrix lists. The engine cell says not disclosed, the same gamble with your layout this page's engine column warns about. Pricing is 0.79 cents at 10,000 screenshots, billed per screenshot, and the PDF endpoint is a side door: document work has nowhere near the knobs the image side has. Storage export is documented, webhooks ship, and there is no polling. Choose them when the deliverable is images in exactly those formats and the PDF is incidental.

[APITemplate.io](https://apitemplate.io/pricing/) is a template-first generator where the stored template is the product: list, get and update endpoints on the templates row, which on this shortlist only PDFShift's /templates CRUD matches. Its engine cell is the field's strangest: no engine named, with a rendering_engine_version knob offering 97 or 153, a version knob without an engine behind a name. Pricing is 0.63 cents a document at 3,000 a month, with 50 a month free, and the tool reads template-shaped end to end: a generation delay instead of a wait selector, no caller css or script, no source-fetch controls, and webp absent from the formats row. Storage export and a sandbox are documented. Choose them when your documents are generated from stored templates and the template API is the point.

## About ours

webcapture is the last row, and the bias is disclosed in the row itself. Pro costs 0.38 cents a document at 5,000 a month; Scale costs about 0.197 cents at 35,000. Both have a 429-not-overage policy: bursts beyond quota wait instead of billing. Where we genuinely lose, the recommendation is not softened: if you want free and can run containers, Gotenberg is the best answer. These rates use different volume bands, so compare the total at your own volume rather than extrapolating one row. Our plans are published on [our pricing page](https://webcapture.io/pricing).

Going deeper: [the HTML to PDF API](https://webcapture.io/html-to-pdf-api) states engine, pricing, and a curl quick start on one page, [the free HTML to PDF API page](https://webcapture.io/free-html-to-pdf-api) prices the $0 route in detail, [HTML to PDF without losing formatting](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains why converted pages break and how browser rendering keeps them intact, and [open source HTML to PDF APIs, compared](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the self-hosted routes. For capability rather than price, the [capability matrix](https://webcapture.io/compare) sets eight vendors on the same fourteen rows, each cell read from the vendor's own documentation. The [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) maps the archived tool flag by flag. The [HTML to PDF guides](https://webcapture.io/guides) hold the migration walk-throughs those two links belong to.

## Method

Checked 2026-09-11 against each vendor's own pricing page, printing engines as disclosed and prices as listed; DocRaptor's plan row re-checked against their plans page on 2026-09-19. Rows for Pdfcrowd, CraftMyPDF and ScreenshotOne checked 2026-10-03; the rest 2026-09-11. Source URLs and PDFShift's Boost overage checked 2026-10-24. APITemplate's Chromium disclosure, annual billing rate and catch, ScreenshotOne's credit and PDF-option catch, ConvertAPI's concurrency ladder, and Api2Pdf's paid rate and unverified free-credit label were corrected on 2026-10-26. The selection compares documented price/capability, not output quality or uptime; common-fixture evidence covers only this service, and no vendor on this page was benchmarked with credentialed calls. Questions or corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### HTML to PDF API

# HTML to PDF API

webcapture converts a URL into a PDF by rendering it in a real Chromium browser: JavaScript executes and web fonts load from the page before the print. The output follows print CSS, and print CSS is not the same thing as the screen view: each request's `colorScheme` picks the palette the page sees, and print-keyed media queries can be forced per request with `usePrint`. One API call, one flat price per document.

Public http(s) targets only. Private and local addresses are refused; bot protection, access controls, unreachable pages, and page errors can prevent output. [Target limits and render errors](https://webcapture.io/docs#errors).

## How it works

One authenticated call carries the page you want printed:

```
curl -X POST \
  https://webcapture.io/v1/convert \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/report"}'

200 {"id": "cnv_9f2c01ab", "bytes": 34121, "remaining": 49,
     "download": "https://webcapture.io/v1/conversions/cnv_9f2c01ab.pdf"}
```

The response carries the finished document. Pass `?wait=false` to get a job ticket instead and poll the status endpoint, or subscribe a signed webhook on Pro or Scale. This page is the summary, not the reference; the exact endpoint shape, the full option list, and the error codes live in the [API docs](https://webcapture.io/docs).

## The engine, stated

Every conversion renders in a real Chromium: a fresh browser context loads the page, the render waits inside its configured limits, and only then prints. JavaScript executes before the print, so client-rendered dashboards and single-page apps get their hydration pass, and thin-content detection retries a page that renders almost nothing instead of printing it half-empty. Web fonts load from the page itself, so the output carries the typefaces the design calls for rather than whatever happens to be installed on the render host. Layout comes from the same engine that prints, so flexbox and grid draw with the browser's own support rather than a converter's reimplementation. And there is no smart-shrinking to fight: the compensation scales and hand-tuned zoom workarounds that archived tools demanded have nothing to compensate for here, so migrated templates just lose them.

## Who this is for

Three kinds of team get the most out of this endpoint. The first prints documents from templates it already has: an invoice, a monthly report, a certificate, a receipt. The template is HTML the team controls, the call is one POST from any language that speaks HTTP, and there is no headless browser fleet to patch and no print stylesheet to keep in step with the screen one. Raw HTML works the same way: POST the document built from your own data instead of a URL, and name a public base url so the document's relative images and stylesheets resolve. The second is automation builders: the same endpoint written out step by step for eight no-code and agent platforms on the [automation guides](https://webcapture.io/automations), each page in that platform's own field names, and the pattern is the same two moves everywhere: POST the source with your key, then GET the download with the same header.

The third is teams still on wkhtmltopdf, which was archived in 2023 and whose WebKit has not been updated since 2012; the [alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) maps each wrapper and language binding to this same switch. Then the honesty this page owes you, before you build: the target must be a public http(s) URL. The API does not log in for you, private and local addresses are refused, and a page behind bot protection or access controls can fail to render. The [error table](https://webcapture.io/docs#errors) shows what each of those failures looks like, so they can be designed for rather than discovered in production.

## Quick start in Python

The same two moves in Python, with the requests library doing the HTTP:

```
pip install requests
```

```
import requests

r = requests.post(
    "https://webcapture.io/v1/convert",
    headers={"Authorization": "Bearer wc_your_key"},
    json={"url": "https://example.com/report"},
    timeout=60,
)
r.raise_for_status()
pdf = requests.get(r.json()["download"], headers={"Authorization": "Bearer wc_your_key"}, timeout=60)
open("report.pdf", "wb").write(pdf.content)
```

One habit to copy from this snippet: the second request repeats the Authorization header. The download url in the receipt is key-scoped, so the GET without the header answers 401 and another key's id answers 404; the url on its own is not a shareable link. Keep the key in a server-side secret for the same reason: it renders pages on your account, so it never belongs in a browser bundle or client-side code.

## Quick start in Node.js

Node 18+ ships fetch, so the same two moves need no dependency beyond the standard library:

```
import { writeFile } from "node:fs/promises";

const res = await fetch("https://webcapture.io/v1/convert", {
  method: "POST",
  headers: { Authorization: "Bearer wc_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://example.com/report" }),
});
const receipt = await res.json();
const pdf = await fetch(receipt.download, { headers: { Authorization: "Bearer wc_your_key" } });
await writeFile("report.pdf", Buffer.from(await pdf.arrayBuffer()));
```

Same shape, same header discipline: the POST reads a receipt, and the GET that carries the bytes repeats the Authorization header, because the download endpoint answers only to the key that made the render.

## What the call returns

Both quick starts read the same receipt. By default the POST holds the connection until the render finishes and answers 200 with one JSON object naming the render: id, bytes, attempts, remaining, sourceUrl, sourceType, and download. The fields a client acts on:

- **id**: the conversion's id. Every later call uses the id from this response; the download path appends .pdf, the poll path does not.
- **bytes**: the finished document's size, so a queue worker can log without a second call.
- **attempts**: the render passes the conversion used; a clean render reads 1.
- **remaining**: renders left on the monthly quota after this one.
- **download**: the GET url for the bytes, key-scoped like the rest: no header answers 401, another key's id answers 404.

Two calling modes sit beside the default. Pass `?wait=false` and the endpoint answers a 202 ticket carrying poll and download urls instead of holding the connection; poll the status endpoint, no faster than about once a second, and renders usually finish in seconds rather than minutes. Prefer push to poll? A key with a webhook url receives a signed webhook when a conversion completes, a Pro and Scale feature. Both modes, the poll states, and the webhook signature are specified in the [API docs](https://webcapture.io/docs), and finished artifacts are kept for at least seven days, 30 on Scale.

## Costs at real volumes

Pricing scales once, not per page: a render bills as one document however heavy the page is, with no per-output-size meter. The first 50 renders a month are free on every account, and a steady month of 5,000 renders is the Pro plan at $19 a month. Above that, 35,000 renders a month runs $69 on Scale, and annual billing takes two months off both. The policy that decides real bills is the quota edge: an exhausted quota answers 429 with the used count, the reset date, and an upgrade url, and never an overage charge. A render that fails or is rejected does not consume quota either; only completed renders count, and the account address gets one email at 80% and one at 100% of quota so the wall is never a surprise. The full ladder is on [the pricing page](https://webcapture.io/pricing).

## Pricing, legible

| Plan | Price | Renders |
| --- | --- | --- |
| Free | $0/month | 50 renders/month |
| Pro | $19/month | 5,000 renders/month |
| Scale | $69/month | 35,000 renders/month |

Every plan includes the full API, async jobs, polling, the MCP endpoint, and all output formats (PDF, PNG, JPEG, WebP, Markdown). Signed webhooks and higher concurrency start at Pro; Scale extends artifact retention to 30 days. There is no per-document overage: an exhausted quota returns 429 until the month rolls over. That policy is a feature, and its trade-off is named honestly: bursts beyond quota wait instead of billing, so the invoice never surprises anyone. Quotas are per account; when one pool is not enough, [email us](mailto:hello@webcapture.io) and we will size a plan.

## Where it wins

- **Fidelity pages**: dashboards, invoices, and statements generated from live app views, where a converter without a browser engine drops the layout, the fonts, or the script-rendered content entirely.
- **Scheduled reports**: nightly or weekly jobs that print the same URL on every run and need run-over-run consistency. A fixed engine and configuration keep output repeatable only while the source page, its assets, its fonts, and the engine version stay unchanged.
- **Automation of record-keeping**: pipelines that archive pages, receipts, or confirmations into a document store through the API and, on paid tiers, its signed webhooks, with nobody clicking print.

## The decision table

The five rows buyers argue about first, all eight vendors a shortlist holds, every cell read from the vendor's own documentation and rendered from the same records the [capability comparison](https://webcapture.io/compare) serves:

| Decision | [webcapture](https://webcapture.io/docs) | [PDFShift](https://docs.pdfshift.io/openapi.json) | [DocRaptor](https://docraptor.com/documentation/api) | [Gotenberg](https://gotenberg.dev/docs) | [Urlbox](https://urlbox.com/docs/options.md) | [Browserless](https://docs.browserless.io/openapi.json) | [ScreenshotOne](https://screenshotone.com/docs/options/) | [APITemplate.io](https://apitemplate.io/docs/pdf-generation/ways-to-generate-pdf/) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Rendering engine | ✓ Chromium, disclosed | ✓ Chromium, disclosed | ✓ Prince, disclosed (not a browser engine) | ✓ Chromium for HTML; LibreOffice for office documents | ✓ Chromium, with a per-request engine_version pin (stable, latest, experimental) | ✓ the endpoint path names the engine (Chrome, Chromium, Edge); no per-request version pin | ✗ not disclosed | ✓ Chromium (disclosed); engine pin not observed in current v2 docs |
| Output formats | ✓ pdf, png, jpeg, webp, markdown | ✓ pdf, png, jpeg, webp; markdown not offered | ✓ pdf only | ✓ pdf, png, jpeg, webp; markdown not offered | ✓ pdf, png, jpeg, webp, markdown (format=md) | ✓ pdf; png, jpeg, webp via options.type; markdown on /smart-scrape, not /pdf | ✓ pdf, png, jpeg, webp, markdown, plus gif, jp2, tiff, avif, heif | ✓ pdf, png, jpeg; webp not observed in current v2 docs |
| Calling modes: sync, async polling, webhooks | ✓ sync by default; a wait=false 202 ticket with poll and download URLs; HMAC-signed webhooks on Pro and Scale | ✓ sync by default; async needs a webhook; the spec has no polling endpoint (re-checked 2026-10-03) | ✓ sync, async with a status URL, and webhooks | ✓ webhooks; no polling | ✓ sync and async with a statusUrl; webhooks | ◐ /pdf and /screenshot answer synchronously; id polling exists on /crawl and /agent/run, not on document conversion | ✓ webhooks; no polling | ✓ webhooks; no polling |
| Free tier | ✓ 50 renders a month, the full API, commercial use fine, the same 60-second timeout as paid | ✓ 50 a month with a 30-second timeout; a keyless trial gives 100 watermarked credits | ✓ 5 documents a month plus unlimited watermarked test documents | ✗ no hosted service at all: you run it | ✗ no free plan, by explicit policy | ✓ 1,000 units, sessions capped at 2 minutes | ✓ 100 screenshots | ✓ 50 a month |
| Price at the measured band | ✓ one render per document, any size: Pro 0.38 cents at 5,000 ($19); Scale about 0.197 cents at 35,000 ($69) | ✓ one credit per 5Mb of output: 0.396 cents at 25,000; a 14Mb document costs 3 credits | ✓ 2.98 cents at 5,000 | ✓ free, self-hosted: the cost is your own container ops | ✓ from $49 for 5,000 renders (0.98 cents), and a render is also spent per 30 seconds of render time and per 5Mb | ✓ per browser-time unit, not per document: $25 for 20,000 units, one unit being up to 30 seconds of browser time | ✓ 0.79 cents at 10,000 screenshots | ✓ 0.63 cents at 3,000 on annual billing |

Fourteen rows, the check dates, and our own gaps printed with the vendor to choose instead: the [full comparison](https://webcapture.io/compare) holds them.

## See for yourself

The claims above are checked against the alternatives: [the best HTML to PDF API](https://webcapture.io/best-html-to-pdf-api) weighs the whole field, and [the free HTML to PDF API page](https://webcapture.io/free-html-to-pdf-api) prices the $0 route in detail. [HTML to PDF without losing formatting](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains why converted pages break and how browser rendering keeps layout, fonts, and backgrounds intact, while [open source HTML to PDF APIs, compared](https://webcapture.io/guides/open-source-html-to-pdf-api) puts real per-document numbers next to the self-hosted routes. Migrating off the archived tool? Start at the [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives). And for engine head-to-heads, the [comparisons](https://webcapture.io/vs/gotenberg-vs-puppeteer) open with Gotenberg vs Puppeteer. Those two live in the [HTML to PDF guides](https://webcapture.io/guides), which hold the migration walk-throughs.

## Method

Checked 2026-09-11 against our published pricing (/pricing), the API reference, and the render pipeline this service runs; the wait and fidelity claims were corrected on 2026-10-26 against the Playwright page documentation. Questions or corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### HTML to Image API

# HTML to Image API

webcapture turns a URL into a PNG, JPEG, or WebP image by rendering it in a real Chromium browser: JavaScript executes, and the capture waits inside the request's configured limits before the shutter opens. Same endpoint, same pricing, and the same engine as [the HTML to PDF API](https://webcapture.io/html-to-pdf-api).

Public http(s) targets only. Private and local addresses are refused; bot protection, access controls, unreachable pages, and page errors can prevent output. [Target limits and render errors](https://webcapture.io/docs#errors).

## How it works

One authenticated call carries the page and the image options:

```
curl -X POST \
  https://webcapture.io/v1/convert \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/dashboard", "format": "png", "viewport": {"width": 1280, "height": 800}}'

200 {"id": "cnv_9f2c01ab", "bytes": 187442, "remaining": 49,
     "download": "https://webcapture.io/v1/conversions/cnv_9f2c01ab.pdf"}
```

The response carries the finished image. The download path keeps its legacy .pdf suffix for every format; the content type and the filename tell the truth. Pass `?wait=false` to get a job ticket instead and poll the status endpoint, or subscribe a signed webhook on Pro or Scale. This page is the summary; the full option list and the error codes live in the [API docs](https://webcapture.io/docs).

## Three image formats, one call

| Format | Best for |
| --- | --- |
| PNG | Lossless captures: dashboards, charts, pixel-exact archives. |
| JPEG | Photographic pages where smaller bytes beat perfect edges. |
| WebP | Often the smallest delivery at a given quality, though not always; modern browsers decode it everywhere. |

The same call also returns `format: "pdf"` and `format: "markdown"`; the PDF side has its own [product page](https://webcapture.io/html-to-pdf-api).

## Viewport, full page, and waiting

- **fullPage** defaults to true: the capture reaches the whole scroll height, not just the first viewport. Set it false for an above-the-fold shot.
- **viewport** sizes the layout between 200 and 7680 pixels wide before navigation, so responsive breakpoints render at the size you name.
- **waitFor** holds the shutter until a selector appears inside the wait budget, so client-rendered widgets can finish before the pixels are taken.
- **colorScheme** renders light, dark, or the site's own preference per request.

## Where it wins

- **Social and preview cards**: open graph and share images generated from live pages on publish, not hand-made in an editor.
- **Monitoring snapshots**: a cron renders the same URL every run, and repeatable rendering (the same source, assets, fonts, and engine version) makes drift visible in the bytes.
- **Agent pipelines**: workflows that need pixels rather than documents hand a URL to one endpoint and get image bytes back on the connection.

## Pricing, legible

| Plan | Price | Renders |
| --- | --- | --- |
| Free | $0/month | 50 renders/month |
| Pro | $19/month | 5,000 renders/month |
| Scale | $69/month | 35,000 renders/month |

Every plan includes the full API and all output formats; images and PDFs draw from the same monthly quota. An exhausted quota returns 429 until the month rolls over, never an overage bill. Quotas are per account; when one pool is not enough, [email us](mailto:hello@webcapture.io) and we will size a plan.

## See for yourself

The [free URL to PDF converter](https://webcapture.io/tools/url-to-pdf) runs this engine in your browser with no account, [the field comparison](https://webcapture.io/best-html-to-pdf-api) prices the alternatives with the catches printed, and the claims above are checked against the [API reference](https://webcapture.io/docs).

## Method

Checked 2026-10-26 against the API reference (/docs) and the render pipeline this service runs. Questions or corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### HTML to PDF without losing formatting

# HTML to PDF without losing formatting

Formatting breaks because the converter is not the browser that displayed the page. The fix is to render with the same engine class your users see: a real browser engine executes the JavaScript, loads the web fonts, and lays out flexbox and grid, then prints. That is what "rendered by a real browser engine" means, and it is the only class of converter that can reproduce a page you can already see.

## The three ways formatting gets lost

Most "my PDF looks wrong" reports fall into three buckets, with the sheet itself (the size and margins `@page` declares against the print call's own format) as the fourth, covered in the worked example below. First, the engine cannot parse the layout: WebKit-era converters like wkhtmltopdf predate flexbox and grid, so modern sheets silently fall back. Second, the page was not finished: JavaScript-rendered content and web fonts get captured before they exist, which produces blank sections and fallback serif text. Third, print assumptions differ: margins, page size, and background colors are print settings, and converters that guess them differently shrink, clip, or drop backgrounds. Each bucket has a different fix, and only the first one requires changing tools.

## Fix 1: finish the page before printing

Wait for the content your document depends on to exist before calling print; there is no single readiness signal that proves every page. In [Playwright](https://playwright.dev/docs/api/class-page), `waitUntil: "networkidle"` is optional and discouraged as a universal readiness proof, so prefer a page-specific wait for what your page renders client-side (`page.waitForFunction()` or `locator.waitFor()`), plus web fonts and images where the print needs them, and pass `printBackground: true` or backgrounds vanish. Set margins and format explicitly instead of inheriting printer defaults. On a hosted renderer these are engine defaults: backgrounds print by default, and what you tune per request is the format, scale, page ranges, the color scheme the page sees, and header or footer templates the API reference lists. Playwright callers who ran page.emulateMedia({ colorScheme: "dark" }) before printing should pass "colorScheme": "dark" instead; it is the same emulation at the same layer.

## Fix 2: stop fighting smart shrinking

wkhtmltopdf shrank content to fit unless you passed `--disable-smart-shrinking`; tuned templates are full of workarounds for it. The project's [repository is archived](https://github.com/wkhtmltopdf/wkhtmltopdf), so those workarounds have nowhere left to be fixed. [Chromium's print pipeline](https://chromium.googlesource.com/chromium/src/+/main/headless/README.md) does not smart-shrink, so migrating templates usually means deleting those workarounds, not rewriting them.

On a hosted renderer the whole checklist is one call; webcapture prints backgrounds by default, and `waitForNetwork` opts the render into waiting for the network to go quiet before the print:

```
curl -X POST https://webcapture.io/v1/convert \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/report",
       "scale": 1, "pageRanges": "1-3"}'
```

## Fix 3: check the engine before the CSS

If your page leans on client-side rendering, only a browser-engine converter reproduces it: print-CSS engines (WeasyPrint, Prince) run no page JavaScript. For layout, WeasyPrint's current documentation supports simple Flexbox and Grid cases with limitations, so heavier layouts still want a browser. Print-CSS engines are excellent for documents authored for print, and the wrong tool for reproducing a web app screen. The decision table in our [wkhtmltopdf alternatives guide](https://webcapture.io/wkhtmltopdf-alternatives) maps engines to page types.

## A worked breakage: page break splits text

The autocomplete-phrased version of this problem is "convert html to pdf page break splits text". In browser rendering, pagination is CSS: `break-inside: avoid` keeps cards and table rows whole, `break-after: avoid` stops a heading from stranding at the bottom of a page, and the `orphans` and `widows` properties keep paragraphs from leaving lone lines behind at a break; the legacy `page-break-*` aliases still work.

```
/* the sheet: @page size, and how the print call treats it */
@page { size: A4; margin: 18mm; }
/* keep cards and rows whole across page breaks */
.card, tr { break-inside: avoid; }
/* a heading never strands at the bottom of a page */
h2 { break-after: avoid; }
/* no lone first or last lines at a page break */
p { orphans: 3; widows: 3; }
/* forced breaks are scoped to a class, never every h2 */
.chapter { break-before: page; }   /* legacy page-break-before: page also works */
```

The sheet size has two claimants and one winner. Playwright's `page.pdf()` takes an explicit `format` ("A4"), and it also takes `preferCSSPageSize`: with it true, a size the page declares in `@page` wins over the call's `format`; with it false, the default, the call's format wins and the page's declared size is ignored for sizing. Pick one source of truth for the sheet; setting both without knowing which one wins is how documents come out with surprise page sizes and margins.

If lines split mid-glyph in your current output, the old engine drawing text in slices is one cause. After a migration, mid-glyph clipping can also come from fixed heights, overflow, transforms, or pagination CSS, so check those before blaming the engine.

## A worked example: the page that will not fit

Take the page every dashboard ships. A flex row holds a sidebar and a content column, the content column is a flex wrap of cards, and every card paints a gradient background. On screen it is one tidy screen. Printed raw on A4, three things go wrong. The first sheet ends in the middle of a card, so its numbers split across the break. The card backgrounds vanish, because print defaults save ink. And the content runs nearly edge to edge, because the page box came from printer defaults nobody chose.

```
/* the screen stylesheet, printed raw */
body { display: flex; }              /* sidebar plus content */
.cards { display: flex; flex-wrap: wrap; gap: 12px; }
.card { background: linear-gradient(#fff, #eef); border-radius: 8px; }
```

The page-break fix above is step one: it stops lines splitting mid-glyph and headings starting mid-page. A dashboard page needs three more declarations, and this is the whole checklist applied in order:

```
/* declare the page box once, in CSS */
@page { size: A4; margin: 16mm; }
/* a card is one unit: it moves whole or it does not move */
.card, tr { break-inside: avoid; }
/* print the colors the screen shows */
.card { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
```

Why each line works. [@page](https://developer.mozilla.org/en-US/docs/Web/CSS/@page) is CSS's own declaration of the page box: the size and margins it sets apply to every sheet the render produces, so the layout stops inheriting whatever a printer dialog or a converter guessed. [break-inside: avoid](https://developer.mozilla.org/en-US/docs/Web/CSS/break-inside) tells the fragmentation engine that a card, or a table row, is one unbreakable unit; when the next card does not fit under the last one, it moves whole to the fresh sheet instead of splitting mid-row. Table rows get the same guard, because a row sliced across two sheets is the table twin of the same mistake. [print-color-adjust: exact](https://developer.mozilla.org/en-US/docs/Web/CSS/print-color-adjust) withdraws the engine's ink-saving license: unless an element claims exact, the user agent may swap expensive colors for cheaper ones, and gradients are usually the first to go. The prefixed twin says the same thing in the dialect older WebKit print paths still speak.

Print the fixed stylesheet and the difference is visible sheet by sheet. Sheets end after the last card that fits. Whole cards ride to the next sheet. Backgrounds hold their colors. Every sheet carries the same 16mm frame. Verify it the way you would verify any layout claim: convert the page again and read three sheets. The break lands between cards, not inside one. The gradient sits on every card. The margin frame is even on both edges of every sheet. This is Fix 1 through Fix 3 applied to one page: a browser engine renders it, the render waits for the page to be finished, and the print assumptions are declared instead of guessed.

## What never survives printing

Some losses are the medium's, not the converter's. A PDF is ink on a fixed sheet, and the things below depend on a live screen, so no engine choice repairs them. The fix is always print CSS, declared once:

- **Hover states.** A sheet has no pointer, so content that appears on hover was never there to print. State the information in the default view, or reveal it under `@media print`.
- **Video and embedded players.** A printed page cannot play, and most engines print a video element as an empty box. Swap in a poster frame image, or a link to the page that plays it.
- **Fixed-position chrome.** `position: fixed` toolbars and cookie notices repeat on every sheet and stamp over the running text. Hide the chrome under `@media print`.
- **`position: sticky`.** Sticky positioning is scroll-relative and paper does not scroll, so sticky elements print at their ordinary place in the flow. Demote them to static in the print stylesheet.

Read the four together and they are one rule: print is a different medium, and `@media print` is where the page says what that medium gets. An engine that prints what the browser already computed executes exactly that stylesheet, no more and no less. The same rules do nothing on a screen, so one stylesheet serves both mediums without forking the page.

## Method and sources

Checked 2026-10-28 against the [Playwright page.pdf() documentation](https://playwright.dev/docs/api/class-page#page-pdf) (page.pdf() prints with print CSS media, networkidle is marked DISCOURAGED on page.goto, and page.pdf's preferCSSPageSize gives a page-declared @page size priority over the call's format), the Chromium print pipeline documentation, and the MDN references linked above. This guide is maintained by webcapture, a hosted renderer that runs a real Chromium for every job; the [API reference](https://webcapture.io/docs) lists what you can tune per request (format, scale, page ranges, header and footer templates), and backgrounds with A4 rendering are engine defaults. The [guides index](https://webcapture.io/guides) lists every comparison and migration path on the site. The [recipes hub](https://webcapture.io/guides/recipes) turns the hosted call into runnable programs in curl, Python, Node, and TypeScript.

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

### Open source HTML to PDF APIs, compared

# Two open browser-rendering routes, compared

The open-source routes that render a page like a browser are two. [Gotenberg](https://gotenberg.org) puts Chromium behind a REST API in one Docker container, MIT license. Self-running [Playwright](https://playwright.dev) is the other: Chromium, your code drives it. Both are genuinely free and both hand you the engine operations: version upgrades, concurrency, and failure handling are yours. Hosted APIs charge per document instead; they are the escape hatch, not a route on this comparison. The comparison is below, and the per-document prices for the hosted routes are on the best-of page.

## The routes side by side

| Route | Engine | Cost basis | You operate |
| --- | --- | --- | --- |
| Gotenberg (self-hosted) | Chromium + LibreOffice in Docker | MIT license, your infrastructure | Upgrades, scaling, retries, monitoring |
| Self-run Playwright | Chromium, your code drives it | Free library, your infrastructure | Browser lifecycle, pools, crashes, fonts |
| Hosted per-document APIs | Chromium or Prince, vendor-operated | Per document, flat plans | Nothing beyond the HTTP call |

For the hosted rows, the per-document prices, free tiers, and the catch each vendor buries are measured and printed on [the best HTML to PDF APIs page](https://webcapture.io/best-html-to-pdf-api); webcapture's own plans are published on [our pricing page](https://webcapture.io/pricing). This page stays about the self-hosted routes.

## A third kind, not a third route: print-CSS engines

[WeasyPrint](https://doc.courtbouillon.org/weasyprint/stable/api_reference.html#supported-features) sits outside this comparison on purpose: it is a print-CSS engine, not a browser. It runs no JavaScript, and its current documentation supports simple Flexbox and Grid cases with limitations, so it is excellent for documents authored for print and the wrong tool for reproducing a live page. If your input is authored HTML with no script, add it to your shortlist; this page keeps comparing the two routes that render a page the way a browser does.

## What the free routes really cost

Gotenberg is one container and two commands to start; the running cost is the operations you inherit: tracking Chromium releases, sizing for concurrent renders, queueing burst traffic, and owning every render failure at 2am. Self-running Playwright has the same operations profile plus browser lifecycle code. A hosted call moves those onto the vendor's page of the invoice. Neither is wrong; they are different buyers. If you cannot name who gets paged when rendering breaks, you have chosen self-hosting with nobody on call.

## What self-hosting actually costs

The free part is real, and worth stating plainly: Gotenberg is MIT-licensed and one Docker image, and nobody invoices you for pulling it. Self-running Playwright is a free library on the same terms. The best-of page prices that row free and self-hosted, and its bar note says the rest out loud: you own the container ops. Free describes the license. The bill is what running it costs, and it has four named lines.

The container is the first line. Something starts it, sizes it for concurrent renders, restarts it after a host reboot or a crashed worker, and watches it, because an endpoint that rendered yesterday does not render again until a person notices it stopped. The engines inside are the second: the image carries headless Chromium and, for office documents, LibreOffice, and both move on release schedules that ignore your deploy calendar. Pin the image and the browsers inside age under you; track the tags and every upgrade reopens whether the pages you print today still print the same way tomorrow. Version pinning is therefore not a setup decision but a standing one, and it is yours on either side. The queue is the third. Burst traffic waits behind whatever concurrency the container was sized for, callers time out while the backlog drains, and retries stack on top of the pile, which turns one slow afternoon into an incident with a pager attached. Failure handling is the fourth, the work the comparison pages summarize as container lifecycle: every render that dies is yours to diagnose and re-run. Self-running Playwright swaps the container for browser lifecycle in code, the same bill under a different cover page, since your code now starts, pools, and crashes with the browser. None of this makes the free routes wrong. It makes free a price measured in runbook lines rather than rate cards.

The choice test this site applies elsewhere reads the same here. Choose the container when you can run it, page it, and want the price at zero; what Gotenberg carries beside the render, webhooks, wait controls, and watermark, encryption and metadata post-processing, is the widest feature set of any free tool on the matrix, and what it does not carry is anything hosted: no sandbox, no storage export, no caller css or script injection, because nothing is stored. Choose self-run Playwright when the browser lifecycle belongs inside your application code anyway. Choose hosted when the four lines above are work nobody on your side is free to do.

There is no universal break-even between the free routes and a hosted plan: the honest number depends on your burst profile, and anyone quoting one without your traffic is guessing. A small starting point to measure from for the free routes: 2 vCPU, 4GB RAM, one Chromium worker. Run concurrency 1 initially, measure memory and queueing at your own burst, and scale on what those measurements say, not on averages from someone else's workload. Our own numbers are produced the same way we ask you to measure: a four-fixture capture (a client dashboard, a print-authored invoice, a font-heavy document, a lazy-loaded article) runs through one Chromium at the production worker count of two, and the run records per-job queue waits, durations, and peak memory beside the output hashes; the starting point above is where that measurement begins, not a verdict about your traffic.

## Where the engine question fits

Both routes on this page can run the same Chromium family, but engine versions, wait behavior, and font handling still differ, so output claims deserve a fixture; operations and price shape are the differentiators here. Engine differences matter when leaving the browser family: print-CSS engines and WebKit-era tools have different CSS ceilings, covered in our [wkhtmltopdf migration guide](https://webcapture.io/wkhtmltopdf-alternatives). If you run Gotenberg today, the head-to-heads [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), and [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice) map when the container is the right shape and when it is not.

## The 2am checklist

Strip the cost section down and one question is left standing, the one this site keeps asking on every comparison: who makes you wait when rendering breaks? The checklist starts by naming the person. If the honest answer is whoever happens to notice, the warning from the opening applies: you have chosen self-hosting with nobody on call, and every line below is theirs whether they agreed to it or not.

The queue jams first. A burst lands, the container is sized for ordinary traffic, and documents begin to wait. From the outside the symptom looks like an outage of whatever calls the render: requests time out, callers retry, and each retry lengthens the backlog. The person paged has to tell a broken renderer from a full one, because the fixes differ and only one of them is visible on a graph. Disk comes next. Gotenberg itself stores nothing; the bytes come straight back when the render finishes. But your pipeline writes them somewhere on the way through: temp files, uploads, the archive directory nobody prunes. That volume grows on schedule, and the failure it eventually causes, a full disk in the middle of the night, is invisible in render metrics right up until nothing renders at all. The hosted side of this trade ships the answer as a default rather than a chore: rendered files are deleted automatically on the plan's retention window (7 days on Free and Pro, 30 on Scale), the contract the hosted config carries out of the box. Engine drift closes the list. The Chromium you pinned in spring is several releases behind by winter, and the first symptom is rarely an alert; it is a ticket saying this page printed differently last month. Upgrade, and you re-test every layout that matters; stay pinned, and the drift compounds quietly under a version number that has stopped meaning anything.

That list is the real price of the free license, and it is also the honest hand-off. Hosted rendering exists so this list is somebody else's: the [HTML to PDF API](https://webcapture.io/html-to-pdf-api) is the same trade with the container, the Chromium upgrades, the queue headroom, and the retention rule operated in one place, and the accountability is the site's own: the inbox that answers the support mail is the inbox that ships the fixes. If a free image plus a pager sounds like a fair deal, run it well; the checklist above is the whole job. If it does not, that page is where the comparison ends.

## Method

Checked 2026-10-28 against the [Gotenberg repository](https://github.com/gotenberg/gotenberg) (still the Docker-based document conversion API, MIT license, actively maintained) and the [Playwright repository](https://github.com/microsoft/playwright) (still Microsoft's browser automation library, Apache-2.0 license, actively maintained, `page.pdf()` on Chromium); an earlier check ran 2026-09-20. Hosted per-document prices quoted on this page are measured in the [best-of comparison](https://webcapture.io/best-html-to-pdf-api). Questions: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### wkhtmltopdf alternatives

# wkhtmltopdf alternatives

wkhtmltopdf was archived on Jan 2, 2023 and is read-only. The direct replacement is browser rendering: headless Chromium prints HTML with modern CSS, web fonts, and JavaScript already applied. This guide maps the common wkhtmltopdf flags to their browser equivalents and names the ones with no equivalent, with per-language migration pages for Python, PHP, C#, Java, Go.

## What happened to wkhtmltopdf

The wkhtmltopdf project was archived by the owner on Jan 2, 2023: the repository is read-only, releases have stopped, and with them the security fixes. The deeper consequence is the engine. wkhtmltopdf renders with a WebKit build from before flexbox and grid existed, so modern CSS silently falls back no matter how carefully the flags are tuned. That ceiling never moves again, because the code that would move it is archived.

## The common flags, mapped

The table below covers the common wkhtmltopdf flags, and the groups migrators ask about first get full rows: TOC and outlines, forms and authentication, load-error handling, and image/DPI. Anything not listed here is not mapped; the archived project's own usage documentation (linked in Method, below) is the complete flag reference.

| wkhtmltopdf flag | Browser (Playwright/Chromium) equivalent |
| --- | --- |
| `--page-size A4` | `page.pdf({ format: "A4" })` |
| `--orientation Landscape` | `page.pdf({ landscape: true })` |
| `--margin-*` | `page.pdf({ margin: { top, right, bottom, left } })` |
| `--zoom 1.5` | `page.pdf({ scale: 1.5 })` |
| `--disable-smart-shrinking` | nothing: Chromium does not smart-shrink; delete the workaround |
| `--print-background` | `page.pdf({ printBackground: true })` |
| `--header-html` / `--footer-html` | `page.pdf({ displayHeaderFooter: true, headerTemplate, footerTemplate })` |
| `--user-style-sheet style.css` | `page.addStyleTag({ path: "style.css" })` |
| `--javascript-delay 500` | `page.waitForFunction()` on the content the page renders late; `waitUntil: "networkidle"` is optional |
| `--toc` / `--toc-depth N` | no equivalent: a browser print call prints the DOM, so the table of contents has to be part of the page itself or dropped; Chromium generates no TOC page |
| `--outline` / `--outline-depth` | PDF bookmarks: Playwright's `page.pdf()` has an `outline` option (v1.42 and later); drivers without the option leave bookmarks unmapped |
| `--post name value` / `--post-file file` | fill and submit the form in the page (`page.fill()` plus a click) before printing: forms and authentication are page interactions, not print options |
| `--username user --password pass` | HTTP basic auth on the browser context: `browser.newContext({ httpCredentials: { username, password } })` (cookies stay on `addCookies`, the row below) |
| `--load-error-handling ignore\|abort\|skip` / `--load-media-error-handling` | load-error handling: no print-call equivalent; Chromium paints what loaded, failed subresources surface on `page.on("requestfailed")`, and you decide in code before printing |
| `--image-dpi` / `--image-quality` | no equivalent: image/DPI control is upstream of the print, since Chromium fetches and paints images at their native resolution; recompress the sources or size them in CSS |
| `--cookie name value` | `browserContext.addCookies([...])` |
| fonts installed on the server | web fonts via CSS `@font-face`, loaded by the page itself |

Rows without a browser equivalent say so outright rather than vanishing. This table covers the common wkhtmltopdf flags only; the archived project's own usage documentation is the complete reference for the rest, and the Method note below links it.

## Pick by language

Each wrapper in each language is just a way of calling the same archived binary, so the migration is the same substitution everywhere with different syntax. Pick your language:

- [Python](https://webcapture.io/wkhtmltopdf-alternatives/python): pdfkit is a thin subprocess around the wkhtmltopdf binary, so every call still renders through the frozen WebKit engine; the page maps each pdfkit option to the Playwright for Python API.
- [PHP](https://webcapture.io/wkhtmltopdf-alternatives/php): snappy shells out to the same binary, so it inherits the archive date, the ended security fixes, and the CSS ceiling; the page shows the Chromium routes that work from PHP.
- [C#](https://webcapture.io/wkhtmltopdf-alternatives/csharp): DinkToPdf P/Invokes the native wkhtmltopdf library, and the native library is the part that was archived; the page maps DinkToPdf settings to the Playwright for .NET API.
- [Java](https://webcapture.io/wkhtmltopdf-alternatives/java): the JVM wrappers exec the same binary and stopped moving when the project did; the page shows the Playwright for Java equivalents for each wrapper call.
- [Go](https://webcapture.io/wkhtmltopdf-alternatives/golang): go-wkhtmltopdf wraps the binary in an exec call, so its output fidelity is capped where wkhtmltopdf stopped; the page maps its calls to Chromium rendering driven from Go.

## Before and after, honestly

Chromium output differs from wkhtmltopdf output on legacy templates, and pretending otherwise is how migrations go wrong at 5pm on a Friday. On templates tuned over the years, most of the differences are deleted workarounds: the smart-shrink compensations, the JavaScript delays, the font installs that vanish once the page loads its own web fonts. Some differences are real layout changes from the newer engine, and those deserve eyes before you cut over. Each per-language migration page pairs the wrapper's before call with the same job in Playwright, so you can see exactly what changes before you commit.

## Going deeper

Two companion guides: [HTML to PDF without losing formatting](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) covers why pages break in conversion and how to keep layout intact, and [the open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the free self-hosted routes against hosted ones. And if you would rather not run the renderer at all, the [webcapture product page](https://webcapture.io/html-to-pdf-api) states engine, pricing, and a curl quick start. The [recipes hub](https://webcapture.io/guides/recipes) shows the same render call as runnable programs, one per page, in curl, Python, Node, and TypeScript.

Compare the engines head to head before you pick: [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice), and [Puppeteer vs wkhtmltopdf](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf).

## Frequently asked questions

### Is wkhtmltopdf still maintained?

No. The project was archived on Jan 2, 2023: the repository is read-only and no fixes have shipped since, including security fixes. The engine underneath is a WebKit build from before flexbox and grid existed.

### What is the best wkhtmltopdf alternative?

Headless Chromium driven by Playwright. It renders HTML with JavaScript executed, web fonts loaded from the page, and modern CSS applied; the hub's before-and-after section shows what a cutover really changes. The flag table above maps the common flags, names the ones with no browser equivalent, and each language page pairs the wrapper's before call with the Playwright after.

### Do I need to rewrite my wkhtmltopdf flags?

Common flags map one-to-one; process lifecycle, waits, and unmatched options need code changes. Page size, orientation, margins, zoom, and print background each map to one option on the print call. Table-of-contents generation has no browser equivalent, and the smart-shrink workarounds are deleted rather than ported: Chromium does not smart-shrink.

### Why do fonts disappear after migrating?

wkhtmltopdf printed with fonts installed on the server, so missing typefaces had to be installed system-wide. Chromium loads the page's own web fonts through @font-face, so the typefaces arrive with the page and the font installs leave the deployment.

### Does Chromium still need --print-background?

The flag becomes printBackground: true on the print call. Hosted renderers usually print backgrounds by default; the per-request tuning moves to format, scale, page ranges, and header or footer templates.

## Method

Checked 2026-09-11 against the [wkhtmltopdf repository](https://github.com/wkhtmltopdf/wkhtmltopdf) archive, whose usage documentation is the complete flag reference for the options the table above does not map, and the current [Playwright print documentation](https://playwright.dev/docs/api/class-page); the table was scoped to the common flags with the unmapped groups named, and the networkidle guidance corrected, on 2026-10-26.

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

### wkhtmltopdf alternative for Python

# wkhtmltopdf alternative for Python

For Python, replace the archived wkhtmltopdf binary with Playwright's Chromium: `page.pdf()` prints the same HTML with modern CSS and web fonts loaded. Most pdfkit options map one to one onto the print call, and the smart-shrinking workarounds in tuned templates have nothing left to compensate for.

pdfkit and weasyprint users feel this first: pdfkit shells out to the dead binary; weasyprint never ran JavaScript at all.

## Install

`pip install playwright` puts the package inside your project's virtualenv; the browser does not live there. `playwright install chromium` downloads Chromium into the OS user cache (a location you can move with `PLAYWRIGHT_BROWSERS_PATH`), and on supported Linux `playwright install --with-deps chromium` also installs the OS dependencies, which land system-wide, not in the virtualenv.

## What you keep: pdfkit options become Playwright arguments

The pdfkit mapping is line for line. Page size becomes `format`, orientation becomes `landscape`, margins become the `margin` object, zoom becomes `scale`, and `--print-background` becomes `print_background=True`. The flag-by-flag table on the [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) applies to Python unchanged.

## What actually changes

pdfkit never rendered anything: it exec'd the binary, so the wkhtmltopdf system package was load-bearing in every virtualenv, container, and CI image. Playwright inverts that: `playwright install` pins the browser next to your pip dependencies, so the render engine's version is a project concern, not a machine concern. The font installs and the smart-shrinking flags the binary demanded have no counterpart to migrate.

Before and after on a typical pdfkit call:

**Before, pdfkit shelling out to the archived binary:**

```
import pdfkit
pdfkit.from_url("https://example.com/report", "out.pdf",
                options={"disable-smart-shrinking": "", "print-background": ""})
```

**After, Playwright printing with Chromium:**

```
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/report", wait_until="networkidle")
    page.pdf(path="out.pdf", print_background=True, format="A4")
    browser.close()
```

Two settings do most of the repair work here. Readiness first: `wait_until="networkidle"` is optional, and Playwright discourages it as a universal readiness proof, so prefer a page-specific wait (a `page.wait_for_function` on late-rendered content, or a font and image wait) when your content loads late; and `print_background=True` keeps background colors and images. Everything else maps from the pdfkit options it replaces.

## Verify before you cut over

Before you cut over, run your worst legacy template through the after snippet above and put the old wkhtmltopdf output beside it. Compare page count, fonts, backgrounds, and page breaks: most differences are the deleted smart-shrink workarounds, and the few that remain are real layout changes from the newer engine.

## WeasyPrint or Chromium?

WeasyPrint wins for documents authored for print: pure Python, strong paged-media CSS, no browser to run. It cannot execute JavaScript, so anything client-rendered must use Chromium.

## Frequently asked questions

### Does pdfkit still work?

It runs, but through the archived binary: pdfkit is a thin subprocess around wkhtmltopdf, so every render inherits the 2023 freeze and no fix will ever arrive. Working today is not the same as safe to depend on.

### Do I have to rewrite my pdfkit options?

Rename them, mostly: format, landscape, margin, scale, and print_background each become one argument of page.pdf(). The hub's flag table covers the stragglers that map to page and context calls instead.

## Method

Checked 2026-09-11 against the [pdfkit option reference](https://pdfkit.org) and the current [Playwright for Python](https://playwright.dev/python/docs/api/class-page) print documentation. The install prerequisites were corrected on 2026-10-26 against the Playwright browser-installation documentation.

---

More languages: [the PHP migration guide](https://webcapture.io/wkhtmltopdf-alternatives/php), [the C# migration guide](https://webcapture.io/wkhtmltopdf-alternatives/csharp), [the Java migration guide](https://webcapture.io/wkhtmltopdf-alternatives/java), [the Go migration guide](https://webcapture.io/wkhtmltopdf-alternatives/golang). The [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) holds the full flag table and the honest before-and-after notes.

Going deeper: the [Gotenberg vs WeasyPrint head-to-head](https://webcapture.io/vs/gotenberg-vs-weasyprint) covers the same print-engine-versus-browser question, the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the free routes against hosted ones, and the [webcapture product page](https://webcapture.io/html-to-pdf-api) states the hosted engine, pricing, and a curl quick start.

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

### wkhtmltopdf alternative for PHP

# wkhtmltopdf alternative for PHP

In PHP the archived binary arrives twice: snappy shells out to it, and the wkhtmltopdf-phar distributions are the same frozen executable reached from PHP. The replacement is Browsershot, which wraps Puppeteer: `->pdf()` prints the same HTML with modern CSS and web fonts loaded from the page, and snappy's options translate method for method.

snappy and the wkhtmltopdf-phar binaries: snappy shells out to the dead binary, and the phar distributions are that same frozen executable reached from PHP.

## Install

`composer require spatie/browsershot`, plus Node.js with Puppeteer beside PHP (`npm install puppeteer` gives Browsershot its browser). No wkhtmltopdf package anywhere in the image.

## What you keep: snappy options become Browsershot calls

Snappy's options translate one to one. Page size becomes `format("A4")`, orientation becomes `landscape()`, margins become `margins($top, $right, $bottom, $left)`, and `"print-background" => true` becomes `showBackground()`. Anything off this list is on the [hub's flag table](https://webcapture.io/wkhtmltopdf-alternatives).

## What actually changes

snappy is a thin shell over the same binary, and PHP deployments feel it hardest: the wkhtmltopdf executable had to exist inside the container, with every font package it needed installed beside it. Browsershot moves that out of the PHP image entirely, so the Dockerfile carries a browser instead of an archived package. The disable-smart-shrinking workarounds go with it.

Before and after on a typical snappy call:

**Before, snappy shelling out to the archived binary:**

```
use Knp\Snappy\Pdf;
$snappy = new Pdf("/usr/local/bin/wkhtmltopdf");
$snappy->generateFromHtml($html, "out.pdf", [
    "print-background" => true,
    "disable-smart-shrinking" => true,
]);
```

**After, Browsershot printing with Chromium:**

```
use Spatie\Browsershot\Browsershot;
Browsershot::url("https://example.com/report")
    ->waitUntilNetworkIdle()
    ->format("A4")
    ->showBackground()
    ->pdf("out.pdf");
```

The repairs live in two calls: `waitUntilNetworkIdle()` is one readiness option (networkidle is optional and discouraged as a universal proof, so a page-specific wait beats it when content loads late), and `showBackground()` keeps background colors and images. Without adding dependencies you can also call the browser directly: `chromium --headless=new --print-to-pdf=out.pdf https://example.com/report`.

## Verify before you cut over

Render your worst legacy template through the Browsershot call above while the old output still exists, and compare page count, fonts, backgrounds, and page breaks. The deleted smart-shrink workarounds explain most differences; anything left is a real layout change to review before the cut-over.

## Native PHP or browser?

Native PHP renderers are maintained, with limited browser fidelity: dompdf, mPDF, and TCPDF parse a limited HTML subset with their own layout code, so they never reproduce a browser page no matter how actively they ship. Keep them for documents authored for PDF; live pages need the browser binary or a hosted API.

## Frequently asked questions

### Does snappy still work?

Yes, until the binary it shells out to stops being installable: snappy is a thin wrapper around wkhtmltopdf, which was archived in 2023 and is disappearing from distro repositories.

### What does Browsershot need on the server?

Node.js with Puppeteer, so the browser lives beside PHP instead of inside it: composer require spatie/browsershot plus an npm install of puppeteer, and no wkhtmltopdf package anywhere in the image.

## Method

Checked 2026-09-11 against the [snappy option reference](https://github.com/KnpLabs/snappy) and the current [Browsershot documentation](https://github.com/spatie/browsershot). The install prerequisites were corrected on 2026-10-26.

---

More languages: [the Python migration guide](https://webcapture.io/wkhtmltopdf-alternatives/python), [the C# migration guide](https://webcapture.io/wkhtmltopdf-alternatives/csharp), [the Java migration guide](https://webcapture.io/wkhtmltopdf-alternatives/java), [the Go migration guide](https://webcapture.io/wkhtmltopdf-alternatives/golang). The [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) holds the full flag table and the honest before-and-after notes.

Going deeper: for the ops-free version of the same switch, the [webcapture product page](https://webcapture.io/html-to-pdf-api) states the hosted engine and pricing on one page, the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the self-hosted alternatives, and [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains what the browser engine fixes.

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

### wkhtmltopdf alternative for C#

# wkhtmltopdf alternative for C#

In C#, the archive arrives with a crash attached: DinkToPdf P/Invokes the native library, and the native interop is the part that breaks under concurrent conversion. Playwright for .NET replaces both: `page.PdfAsync()` prints in a real Chromium, with modern CSS applied and web fonts loaded from the page, and each render runs in an isolated context instead of behind SynchronizedConverter.

DinkToPdf and TuesPechkin wrap the native binary and crash under concurrency: both P/Invoke the archived wkhtmltopdf library, so the frozen engine and the thread-unsafe native layer arrive together.

## Install

`dotnet add package Microsoft.Playwright`, then `pwsh bin/Debug/netX/playwright.ps1 install --with-deps chromium` (replace `netX` with your project's target framework, for example `net8.0`). The NuGet package travels with the project; the browser binaries go to the user cache, and the `--with-deps` system dependencies install machine-wide.

## What you keep: DinkToPdf settings become PdfOptions

The DinkToPdf settings carry over under new names. Paper size becomes `Format = "A4"`, orientation becomes `Landscape = true`, margins become the `Margin` object, and `PrintBackground = true` keeps backgrounds; the [hub's flag map](https://webcapture.io/wkhtmltopdf-alternatives) names the browser equivalent for the rest.

## What actually changes

DinkToPdf P/Invokes the native wkhtmltopdf library, so the freeze arrived twice: the engine stopped moving, and the native interop that loads it is the part that crashes under concurrent conversion, which is why SynchronizedConverter exists at all. Playwright for .NET owns the browser process, hands each render an isolated context, and the serialization guard is guarding a library that is no longer there.

Before and after on a typical DinkToPdf call:

**Before, DinkToPdf P/Invoking the archived native library:**

```
var converter = new SynchronizedConverter(new PdfTools());
var doc = new HtmlToPdfDocument()
{
    GlobalSettings = { PaperSize = PaperKind.A4 },
    Objects = { new ObjectSettings { HtmlContent = html,
        WebSettings = new WebSettings { PrintBackground = true } } },
};
byte[] pdf = converter.Convert(doc);
```

**After, Playwright for .NET printing with Chromium:**

```
using Microsoft.Playwright;
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
await page.GotoAsync("https://example.com/report",
    new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await page.PdfAsync(new PagePdfOptions
    { Path = "out.pdf", Format = "A4", PrintBackground = true });
```

Two properties carry the repair: `WaitUntil = WaitUntilState.NetworkIdle` is one readiness option (networkidle is optional and discouraged as a universal proof, so a page-specific wait beats it when content loads late), and `PrintBackground = true` keeps background colors and images. Each render runs in an isolated browser context, so the concurrent-conversion crashes leave with the P/Invoke.

## Verify before you cut over

Point the after snippet at your worst legacy template while the old converter still runs, and put the two outputs side by side: page count, fonts, backgrounds, page breaks. Most differences are the workarounds you no longer port; the remainder are layout changes that deserve a look before cutover.

## Print-CSS engine or browser?

print-CSS engines stay correct for authored documents only. Prince and WeasyPrint lay out CSS documents written for print, but neither runs the page's JavaScript, so a client-rendered page never arrives intact; those pages must use Chromium.

## Frequently asked questions

### Is DinkToPdf maintained?

No: it P/Invokes the native wkhtmltopdf library, which was archived in 2023, and the native interop is the layer that crashes under concurrent conversions, which is why SynchronizedConverter serializes every call.

### Does Playwright for .NET need anything installed?

Yes, the browsers: the pwsh install script (playwright.ps1 install --with-deps chromium) fetches Chromium into the user cache and adds the system dependencies, so the render engine's version travels with the app while the OS libraries stay a machine concern.

## Method

Checked 2026-09-11 against the [DinkToPdf settings reference](https://github.com/rdvojmoc/DinkToPdf) and the current [Playwright for .NET](https://playwright.dev/dotnet/docs/api/class-page) PDF documentation. The install commands (including the pwsh playwright.ps1 script) were corrected on 2026-10-26 against the Playwright browser-installation documentation.

---

More languages: [the Python migration guide](https://webcapture.io/wkhtmltopdf-alternatives/python), [the PHP migration guide](https://webcapture.io/wkhtmltopdf-alternatives/php), [the Java migration guide](https://webcapture.io/wkhtmltopdf-alternatives/java), [the Go migration guide](https://webcapture.io/wkhtmltopdf-alternatives/golang). The [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) holds the full flag table and the honest before-and-after notes.

Going deeper: [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) walks the breakages the old engine caused and the settings that fix them, the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices free routes against hosted, and the [webcapture product page](https://webcapture.io/html-to-pdf-api) keeps engine, pricing, and quick start together.

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

### wkhtmltopdf alternative for Java

# wkhtmltopdf alternative for Java

On Java, the wrappers inherited the frozen binary, and openhtmltopdf takes a different toll: it only lays out well-formed XHTML, so templates needed normalizing before they printed at all. Playwright for Java retires both: `page.pdf()` prints from the same browser that rendered the page, modern CSS and web fonts included, and the normalization step disappears.

openhtmltopdf and flying-saucer render a CSS subset; wkhtmltopdf-java shells the dead binary. The first pair lays out offline but never runs JavaScript; the wrapper inherits the frozen engine wholesale.

## Install

Add `com.microsoft.playwright:playwright` to the build, then run Playwright's install step once to download the browsers it drives.

## What you keep: wrapper options become Pdf options

The wrapper options survive the trip. Page size becomes `setFormat("A4")`, orientation becomes `setLandscape(true)`, margins become `setMargin()`, and `setPrintBackground(true)` keeps backgrounds.

## What actually changes

The JVM wrappers exec'd the binary, and openhtmltopdf took a third road: laying out a well-formed XHTML subset in Java, which is why templates needed normalizing before they could print. Playwright for Java prints from the browser's own layout, so the XHTML normalization step and the binary-on-PATH both leave the build, and fonts stop being a fontconfig exercise.

Before and after on a typical openhtmltopdf build:

**Before, openhtmltopdf laying out a hand-prepared XHTML subset:**

```
try (OutputStream out = new FileOutputStream("out.pdf")) {
    PdfRendererBuilder builder = new PdfRendererBuilder();
    builder.useFastMode();
    builder.withHtmlContent(xhtml, baseUri);
    builder.toStream(out);
    builder.run();
}
```

**After, Playwright for Java printing with Chromium:**

```
try (Playwright playwright = Playwright.create()) {
    Browser browser = playwright.chromium().launch();
    Page page = browser.newPage();
    page.navigate("https://example.com/report",
        new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
    page.pdf(new Page.PdfOptions().setPath(Paths.get("out.pdf"))
        .setFormat("A4").setPrintBackground(true));
}
```

`setWaitUntil(WaitUntilState.NETWORKIDLE)` is one readiness option (networkidle is optional and discouraged as a universal proof, so a page-specific wait beats it when content loads late), and `setPrintBackground(true)` keeps background colors and images. The browser itself lays the page out, so the XHTML normalization step disappears.

## Verify before you cut over

Run your most fragile template through the after snippet above before you retire the old path, and compare page count, fonts, backgrounds, and page breaks against the current output. The smart-shrink compensations account for most of the delta; the rest are genuine engine differences to review.

## OpenHTMLtoPDF or browser?

openhtmltopdf stays correct for XHTML-authored print documents: deterministic layout, no browser process, solid paged-media CSS. It never runs JavaScript and only accepts well-formed input, so anything client-rendered must use Chromium.

## Frequently asked questions

### Why does openhtmltopdf need XHTML?

It is not a browser: openhtmltopdf lays out a well-formed XHTML subset with its own CSS engine, so templates need normalizing before they print. Chromium prints the page as the browser already laid it out, and the normalization step goes away.

### Do the Java wkhtmltopdf wrappers still work?

They run, but they exec the same binary that was archived on Jan 2, 2023, so their output is capped where the engine froze and no fix is coming.

## Method

Checked 2026-09-11 against the [openhtmltopdf user guide](https://github.com/openhtmltopdf/openhtmltopdf) and the current [Playwright for Java](https://playwright.dev/java/docs/api/class-page) PDF documentation. The install prerequisites were corrected on 2026-10-26.

---

More languages: [the Python migration guide](https://webcapture.io/wkhtmltopdf-alternatives/python), [the PHP migration guide](https://webcapture.io/wkhtmltopdf-alternatives/php), [the C# migration guide](https://webcapture.io/wkhtmltopdf-alternatives/csharp), [the Go migration guide](https://webcapture.io/wkhtmltopdf-alternatives/golang). The [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) holds the full flag table and the honest before-and-after notes.

Going deeper: the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the self-hosted routes against hosted ones, [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) covers what each engine class preserves, and [the webcapture product page](https://webcapture.io/html-to-pdf-api) states the hosted route plainly.

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

### wkhtmltopdf alternative for Go

# wkhtmltopdf alternative for Go

In Go, drive Chromium directly over the DevTools protocol: chromedp's `page.PrintToPDF` command prints the same HTML with modern CSS and web fonts loaded. The wkhtmltopdf flags become print parameters, and the smart-shrinking workarounds have no successor to migrate.

go-wkhtmltopdf shells the dead binary; chromedp talks to Chrome directly over the DevTools protocol. The wrapper inherits the frozen engine; chromedp drives the real thing.

## Install

`go get github.com/chromedp/chromedp`. chromedp drives an installed Chrome or Chromium over the DevTools protocol, so the runtime must supply the Chrome binary itself, its OS shared libraries and sandbox/runtime requirements (a distro chrome package pulls both; bare containers usually need the extra libraries and an explicit sandbox decision).

## What you keep: exec flags become print parameters

The exec flags land on the PrintToPDFParams struct as plain fields. Page size becomes `PaperWidth` and `PaperHeight` in inches, orientation becomes `Landscape: true`, margins become the `MarginTop`, `MarginBottom`, `MarginLeft`, and `MarginRight` fields, and `PrintBackground: true` keeps backgrounds.

## What actually changes

go-wkhtmltopdf is an exec call: a binary on PATH, temp files for input and output, and a hard dependency on a package most distros have stopped shipping. chromedp speaks the DevTools protocol to a real Chrome, so the render becomes bytes in memory and the static binary you ship stays static.

Before and after on a typical go-wkhtmltopdf call:

**Before, go-wkhtmltopdf exec-ing the archived binary:**

```
pdfg, _ := wkhtmltopdf.NewPDFGenerator()
pdfg.AddPage(wkhtmltopdf.NewPage("https://example.com/report"))
pdfg.PageSize.Set("A4")
if err := pdfg.Create(); err != nil {
    log.Fatal(err)
}
pdfg.WriteFile("out.pdf")
```

**After, chromedp printing with Chromium:**

```
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
if err := chromedp.Run(ctx,
    chromedp.Navigate("https://example.com/report"),
); err != nil {
    log.Fatal(err)
}
printBackground := true
res, err := chromedp.Call(ctx,
    page.PrintToPDF,
    page.PrintToPDFParams{PrintBackground: &printBackground},
)
if err != nil {
    log.Fatal(err)
}
if werr := os.WriteFile("out.pdf", res.Data, 0o644); werr != nil {
    log.Fatal(werr)
}
```

The navigate action waits for the load event before the print runs, and `PrintBackground: true` keeps background colors and images. The exec call, the binary on PATH, and the temp files all disappear.

## Verify before you cut over

While the old binary still runs, render your worst template through chromedp and through go-wkhtmltopdf, and diff the results: page count, fonts, backgrounds, page breaks. The deleted shrink workarounds explain most differences, and what remains is a short list of real layout changes to sign off.

## Gotenberg or in-process?

Gotenberg is the ops-heavy-but-free middle: same Chromium, REST in front. One container serves every language and takes the browser lifecycle out of your binary; the cost is operating the service. In-process chromedp wins when rendering belongs inside the Go app with nothing extra to run.

## Frequently asked questions

### Is go-wkhtmltopdf maintained?

The wrapper still works, but it execs the archived binary, and distros have been dropping that package, so fresh builds fail on the missing executable before the engine freeze even matters.

### chromedp or Gotenberg for Go?

chromedp when rendering belongs inside the Go binary with nothing extra to run; Gotenberg when one container should serve every language and you accept operating it.

## Method

Checked 2026-09-11 against the [go-wkhtmltopdf README](https://github.com/SebastiaanKlippert/go-wkhtmltopdf) and the current [chromedp and cdproto](https://github.com/chromedp/chromedp) print documentation. The runtime prerequisites (OS shared libraries, sandbox) were corrected on 2026-10-26.

---

More languages: [the Python migration guide](https://webcapture.io/wkhtmltopdf-alternatives/python), [the PHP migration guide](https://webcapture.io/wkhtmltopdf-alternatives/php), [the C# migration guide](https://webcapture.io/wkhtmltopdf-alternatives/csharp), [the Java migration guide](https://webcapture.io/wkhtmltopdf-alternatives/java). The [wkhtmltopdf alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) holds the full flag table and the honest before-and-after notes.

Going deeper: the [Gotenberg vs Puppeteer head-to-head](https://webcapture.io/vs/gotenberg-vs-puppeteer) maps when the container is the right shape, the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the container against hosted rates, and the [webcapture product page](https://webcapture.io/html-to-pdf-api) is the hosted version of the same switch.

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

## Comparisons

### HTML to PDF API comparison: eight vendors, fourteen rows

# HTML to PDF API comparison: eight vendors, fourteen rows

Eight vendors on fourteen capability rows, every cell read from the vendor's own documentation before it was written, and our own missing cells printed with the vendor to choose instead. This is not a ranking: there is no score and no winner, because the right column depends on the row you came for.

## The matrix

The marks: ✓ means the vendor's own docs state it with a named parameter or endpoint; ✗ means the vendor's documented list is complete and the capability is not on it; ◐ means half, the engine can do it but the knob, the mode or the paperwork is not all there. Where a check could not settle a cell, the cell says so instead of guessing.

| Capability | [webcapture](https://webcapture.io/docs) | [PDFShift](https://docs.pdfshift.io/openapi.json) | [DocRaptor](https://docraptor.com/documentation/api) | [Gotenberg](https://gotenberg.dev/docs) | [Urlbox](https://urlbox.com/docs/options.md) | [Browserless](https://docs.browserless.io/openapi.json) | [ScreenshotOne](https://screenshotone.com/docs/options/) | [APITemplate.io](https://apitemplate.io/docs/pdf-generation/ways-to-generate-pdf/) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Output formatsFormat lists come from each vendor's documented parameters (checked 2026-10-03 to 2026-10-05). | ✓ pdf, png, jpeg, webp, markdown | ✓ pdf, png, jpeg, webp; markdown not offered | ✓ pdf only | ✓ pdf, png, jpeg, webp; markdown not offered | ✓ pdf, png, jpeg, webp, markdown (format=md) | ✓ pdf; png, jpeg, webp via options.type; markdown on /smart-scrape, not /pdf | ✓ pdf, png, jpeg, webp, markdown, plus gif, jp2, tiff, avif, heif | ✓ pdf, png, jpeg; webp not observed in current v2 docs |
| Rendering engineWhere a vendor's docs stay silent about the engine, the cell says so rather than guessing. | ✓ Chromium, disclosed | ✓ Chromium, disclosed | ✓ Prince, disclosed (not a browser engine) | ✓ Chromium for HTML; LibreOffice for office documents | ✓ Chromium, with a per-request engine_version pin (stable, latest, experimental) | ✓ the endpoint path names the engine (Chrome, Chromium, Edge); no per-request version pin | ✗ not disclosed | ✓ Chromium (disclosed); engine pin not observed in current v2 docs |
| Price at the measured bandEach vendor's own published numbers: the hosted rows from our rate card (checked 2026-09-11, PDFShift re-checked 2026-10-03), Urlbox and Browserless pricing pages captured 2026-10-04. | ✓ one render per document, any size: Pro 0.38 cents at 5,000 ($19); Scale about 0.197 cents at 35,000 ($69) | ✓ one credit per 5Mb of output: 0.396 cents at 25,000; a 14Mb document costs 3 credits | ✓ 2.98 cents at 5,000 | ✓ free, self-hosted: the cost is your own container ops | ✓ from $49 for 5,000 renders (0.98 cents), and a render is also spent per 30 seconds of render time and per 5Mb | ✓ per browser-time unit, not per document: $25 for 20,000 units, one unit being up to 30 seconds of browser time | ✓ 0.79 cents at 10,000 screenshots | ✓ 0.63 cents at 3,000 on annual billing |
| Free tier | ✓ 50 renders a month, the full API, commercial use fine, the same 60-second timeout as paid | ✓ 50 a month with a 30-second timeout; a keyless trial gives 100 watermarked credits | ✓ 5 documents a month plus unlimited watermarked test documents | ✗ no hosted service at all: you run it | ✗ no free plan, by explicit policy | ✓ 1,000 units, sessions capped at 2 minutes | ✓ 100 screenshots | ✓ 50 a month |
| Calling modes: sync, async polling, webhooks | ✓ sync by default; a wait=false 202 ticket with poll and download URLs; HMAC-signed webhooks on Pro and Scale | ✓ sync by default; async needs a webhook; the spec has no polling endpoint (re-checked 2026-10-03) | ✓ sync, async with a status URL, and webhooks | ✓ webhooks; no polling | ✓ sync and async with a statusUrl; webhooks | ◐ /pdf and /screenshot answer synchronously; id polling exists on /crawl and /agent/run, not on document conversion | ✓ webhooks; no polling | ✓ webhooks; no polling |
| Wait controls | ✓ settle up to 10,000ms; waitFor on a css selector; waitForNetwork holds for a quiet network (networkidle) | ✓ delay; wait_for (selector or function name); wait_for_network | ◐ a page function (docraptorJavaScriptFinished) signals readiness; no millisecond delay | ✓ waitDelay and waitForExpression | ✓ wait_for and a millisecond delay | ✓ waitForSelector, waitForFunction, waitForTimeout | ✓ wait_for_selector and delay | ◐ a generation delay; no wait selector |
| Injection: css and javascript | ✓ css and javascript as inline strings, up to 20,000 characters each | ✓ css and javascript | ✗ no caller css or script; a prince_options javascript switch exists but takes no caller script | ✗ none | ✓ css and js | ✓ addStyleTag and addScriptTag | ✓ styles and scripts | ✓ css on HTML and Markdown; caller JavaScript injection not observed in current v2 docs |
| PDF post-processing | ✓ a centered text watermark, metadata (title, author, subject, keywords), AES-256 encryption; encryption needs qpdf on the deployment and answers 503 without it | ✓ watermark, metadata, encryption | ◐ metadata and encryption; no watermark | ✓ watermark, encryption, metadata | ◐ metadata (pdf_title and friends); no watermark, no encryption | ✗ none of the three in the documented schema | ✗ none | ✗ none |
| Sandbox and test modes | ✓ a sandbox mode that spends no quota and stamps a watermark footer; the free web tool renders keyless and unwatermarked | ✓ a sandbox, plus a keyless trial of 100 watermarked credits | ✓ test mode with unlimited watermarked documents | ✗ nothing hosted: your container is the test bench | ◐ a sandbox, but it counts against the monthly quota | ◐ an API playground that needs your own token | not observed in their docs as of the 2026-10-03 check | ✓ |
| Source fetch controls (headers, cookies, auth) | ✓ custom headers, basic auth, and cookies pinned to the source hostname; the cookie jar never survives the render | ✓ http_headers, cookies, auth | ✓ | ✓ | ✓ header, cookie, authorization | ✓ setExtraHTTPHeaders, cookies, authenticate | ✓ | not observed in current v2 docs as of the 2026-10-26 check |
| Compliance posture (HIPAA) | ◐ a zero-residue phi mode is shipped and BAA-gated: it answers 403 until an executed BAA exists on the account; the BAA chain is not signed yet | ✓ is_hipaa with a BAA | ✓ HIPAA, SOC 2 and GDPR documented | not observed in their docs as of the 2026-10-03 check; nothing is hosted to certify, the data stays in your own containers | not observed in their docs as of the 2026-10-04 check; SOC 2 Type II and GDPR are documented | ◐ a security band on their pricing page advertises SOC 2 Type II, GDPR and an available HIPAA BAA (2026-10-04); the API spec names no HIPAA mode | ✗ | ✗ |
| Artifact retention and export | ✓ artifacts kept 7 days on free and pro, 30 on scale; export pushes to a caller-signed presigned PUT URL and no cloud credential is stored; sync base64 receipts on request | ✓ S3 delivery through customer credentials stored on their side; a filename turns the response into a JSON url with the file kept two days | ✗ no storage export | ✗ the bytes come straight back; nothing stored | ✓ use_s3 and s3_presigned_url storage options | ✗ no storage export | ✓ storage export | ✓ storage export |
| Self-hosting | ✗ hosted only; if you can host it yourself, Gotenberg below is the honest free answer | ✗ | ✗ | ✓ free and open source; you run the container and carry the ops | ✗ | ✗ | ✗ | ✗ |
| Stored templates | ✗ headers and footers are per-request HTML, not stored templates (see the own-gaps list below) | ✓ /templates CRUD plus generate | not observed in their HTTP reference (checked 2026-10-03) | ✗ | ✗ per-request header and footer HTML only, no template CRUD | ✗ | ✗ | ✓ list-templates documented; get and update not observed in current v2 docs |

## How the prices were read

One line per vendor, at or nearest the band a growing product actually buys at, straight from each vendor's published numbers with the check date on the row. The billable unit matters as much as the number: this service bills one rendered document of any size, PDFShift meters output size, Urlbox meters both render time and output size, and Browserless sells blocks of browser time, so a slow or heavy document changes their bill and not this one.

## The headline gaps

A matrix with one honest column is worth more than a full one, so our own ✗ cells get their own list, each with the vendor that carries the capability today:

- **PDF/A and PDF/UA conformance**: if you need archival or accessibility conformance, DocRaptor or self-hosted Gotenberg carry it today.
- **HTML output (markdown yes, HTML no)**: ScreenshotOne, Urlbox and Browserless all document an html output on their APIs.
- **Stored template APIs**: APITemplate and CraftMyPDF are template-first tools; the stored template is their product, not a bolt-on.
- **PDF merge**: Api2Pdf or Gotenberg merge PDFs after the render.
- **E-invoice output (ZUGFeRD, Factur-X)**: APITemplate's einvoice path is built for it.
- **Image formats beyond png, jpeg and webp (gif, jp2, tiff, avif, heif)**: ScreenshotOne carries all five.
- **A signed HIPAA BAA**: not offered until the operator program completes; DocRaptor and pdfshift carry BAAs today.
- **An engine version pin and a caller-set timeout**: pdfshift exposes both.

## Where to go next

Deep on PDFShift? [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift) prices the two rate cards against each other and [the migration manual](https://webcapture.io/migrate/pdfshift) moves a script in one line. Shopping at zero? [the free HTML to PDF API page](https://webcapture.io/free-html-to-pdf-api) prices the $0 route in detail. The full parameter surface is in [the docs](https://webcapture.io/docs) and every price is on [the pricing page](https://webcapture.io/pricing).

## Method

Every cell was read from the vendor's own documentation before it was written: PDFShift's OpenAPI spec and live docs (re-checked 2026-10-03), DocRaptor's API reference, Gotenberg's routes and modules, Urlbox's options reference (2026-10-04), Browserless's OpenAPI spec (2026-10-04), ScreenshotOne's options table, and APITemplate's API reference; webcapture's rows come from our own docs and rate card. Checked 2026-10-05; webcapture's Pro and Scale prices checked 2026-10-24. APITemplate's v2 citation, Chromium disclosure, annual price, formats, CSS injection, template management, and unresolved cells were corrected on 2026-10-26.

- webcapture: [API documentation](https://webcapture.io/docs); [pricing](https://webcapture.io/pricing).
- PDFShift: [API documentation](https://docs.pdfshift.io/openapi.json); [pricing](https://pdfshift.io/pricing).
- DocRaptor: [API documentation](https://docraptor.com/documentation/api); [pricing](https://docraptor.com/plans).
- Gotenberg: [API documentation](https://gotenberg.dev/docs).
- Urlbox: [API documentation](https://urlbox.com/docs/options.md); [pricing](https://urlbox.com/pricing).
- Browserless: [API documentation](https://docs.browserless.io/openapi.json); [pricing](https://browserless.io/pricing).
- ScreenshotOne: [API documentation](https://screenshotone.com/docs/options/); [pricing](https://screenshotone.com/pricing/).
- APITemplate.io: [API documentation](https://apitemplate.io/docs/pdf-generation/ways-to-generate-pdf/); [pricing](https://apitemplate.io/pricing/).

Corrections and missing-row reports go to [hello@webcapture.io](mailto:hello@webcapture.io).

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

### Gotenberg vs Puppeteer

# Gotenberg vs Puppeteer

Both render with Chromium, and the same engine family is why output is usually close; it is not proof of identical output, because version, wait, font, media, and post-processing differences need a fixture to rule out. The difference worth deciding on is the interface. Gotenberg wraps Chromium in a Docker container behind a REST call, so any language can use it. Puppeteer drives the browser from Node code, so you get finer control from inside your own code, and you own the browser lifecycle. Choose by who makes you wait when rendering breaks.

## Gotenberg vs Puppeteer: what each one is

**Gotenberg**: REST API in one Docker container, MIT license. Engine: headless Chromium (plus LibreOffice for office files). Ops: you run the container: upgrades, scaling, retries. Cost: free license, your infrastructure. Best for language-agnostic teams and self-hosters who want an API shape.

**Puppeteer**: Node.js library that drives Chrome. Engine: headless Chromium via the Chrome DevTools Protocol. Ops: your code starts, pools, and crashes with the browser. Cost: free library, your infrastructure. Best for Node teams needing fine-grained control from inside the app.

## Gotenberg vs Puppeteer at a glance

| Aspect | Gotenberg | Puppeteer |
| --- | --- | --- |
| Engine | Chromium | Chromium |
| Hosting | your Docker | your Node process |
| JavaScript | executed | executed |
| Web fonts | loaded from CSS | loaded from CSS |
| Modern CSS | yes | yes |
| Ops burden | container lifecycle | browser lifecycle in code |
| Cost basis | infra only | infra only |
| Best for | backends in several languages | Node apps |

Same engine family, so output is usually close; the decision is operations, and a fixture settles the edge cases.

## The verdict: when each one wins

**Gotenberg wins:** mixed-language stacks and ops teams that think in containers.

**Puppeteer wins:** Node codebases that want browser control without a network hop.

**Neither wins:** teams that do not want to operate Chromium at all: that is the hosted route, which is what webcapture sells (same engine, ops on us, priced per document at [our published pricing](https://webcapture.io/pricing)).

## How this connects

Decided on the container? The [wkhtmltopdf migration hub](https://webcapture.io/wkhtmltopdf-alternatives) is the same ops trade for teams leaving the archived binary.

Wider than this pair: [the capability matrix](https://webcapture.io/compare) reads eight vendors across fourteen rows, each cell sourced from the vendor's own docs.

Going deeper: the [open-source pricing comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) measures what self-hosting the container really costs next to the hosted rates, [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains why identical engines can still print differently, and if you would rather not operate either side, the [webcapture product page](https://webcapture.io/html-to-pdf-api) is the same trade, hosted.

## Frequently asked questions

### Is Gotenberg free?

The license is free: MIT, one Docker image. What you pay is infrastructure and operations, since the container, its Chromium upgrades, scaling, and retries are yours to run.

### Does Gotenberg run JavaScript?

Yes. Gotenberg renders through a real headless Chromium, so client-side pages, web fonts, and modern CSS all arrive in the print.

### Gotenberg or Puppeteer for a Python or PHP backend?

Gotenberg, in most cases: its REST interface speaks HTTP, so any language can call it. Puppeteer only drives the browser from Node code.

---

More comparisons: [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice), [Puppeteer vs wkhtmltopdf](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf), [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift).

## Method

Checked 2026-09-11: the [Gotenberg repository](https://github.com/gotenberg/gotenberg) and its docs and discussions; both tools speak the same Chrome DevTools Protocol. The fidelity claims were corrected on 2026-10-26: same engine family is not same output, so the verdict leans on documented capability.

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

### Gotenberg vs WeasyPrint

# Gotenberg vs WeasyPrint

These two answer different questions. Gotenberg runs a real Chromium, so it executes JavaScript, loads web fonts from the page, and prints with the browser's own flexbox and grid support. WeasyPrint is a pure-Python print engine with excellent paged-media CSS but no JavaScript: it cannot render a web app faithfully, and its current documentation supports simple Flexbox and Grid cases with limitations. Pick by what you are printing.

## Gotenberg vs WeasyPrint: what each one is

**Gotenberg**: REST API in one Docker container, MIT license; the browser-vs-print split below is about WeasyPrint's engine, not its API. Engine: headless Chromium (plus LibreOffice for office files). Ops: you run the container: upgrades, scaling, retries. Cost: free license, your infrastructure. Best for web apps, SPAs, anything client-rendered.

**WeasyPrint**: pure-Python HTML/CSS to PDF engine, BSD licensed. Engine: its own print engine, no browser. Ops: a pip dependency inside your Python process. Cost: free license, your process. Best for authored print documents where paged-media CSS is the requirement and the HTML never runs script.

## Gotenberg vs WeasyPrint at a glance

| Aspect | Gotenberg | WeasyPrint |
| --- | --- | --- |
| Engine | Chromium | print engine |
| Hosting | your Docker | your Python process |
| JavaScript | executed | not executed |
| Web fonts | loaded from CSS | loaded from CSS @font-face, with API setup |
| Modern CSS | yes | partial (print CSS strong; simple Flexbox and Grid cases with limitations) |
| Ops burden | container lifecycle | library dependency |
| Cost basis | infra only | infra only |
| Best for | live web pages | authored documents |

The engines answer different questions: pages vs documents.

## The verdict: when each one wins

**Gotenberg wins:** pages a browser has to render first: dashboards, invoices from your app, anything with script.

**WeasyPrint wins:** documents authored for print where page headers, footnotes, and running elements matter more than JavaScript.

**Neither wins:** teams that want neither container ops nor a Python dependency in the render path: that is the hosted route, which is what webcapture sells (same Chromium as Gotenberg, ops on us, per-document pricing at [our published pricing](https://webcapture.io/pricing)).

## How this connects

The [Python migration page](https://webcapture.io/wkhtmltopdf-alternatives/python) covers the same choice from the escape side: when to leave WeasyPrint for a browser engine. The [migration hub](https://webcapture.io/wkhtmltopdf-alternatives) maps the archived binary flag by flag.

Wider than this pair: [the capability matrix](https://webcapture.io/compare) reads eight vendors across fourteen rows, each cell sourced from the vendor's own docs.

Going deeper: [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains what each engine class preserves and what it drops, the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the free routes against hosted ones, and the [webcapture product page](https://webcapture.io/html-to-pdf-api) keeps the hosted engine, prices, and quick start on one page.

## Frequently asked questions

### Does WeasyPrint run JavaScript?

No. WeasyPrint is a pure-Python print engine: it never executes page script, so a client-rendered page prints without the content the script would have added.

### Is WeasyPrint good for web pages?

For authored documents, yes, and its paged-media CSS is excellent. For live web pages, mostly no: current WeasyPrint documentation supports simple Flexbox and Grid cases with limitations, and JavaScript-rendered content still requires a browser.

### Can Gotenberg print office documents?

Yes. Gotenberg bundles LibreOffice alongside Chromium, so .docx, .xlsx, and .pptx conversions ride the same container.

---

More comparisons: [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice), [Puppeteer vs wkhtmltopdf](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf), [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift).

## Method

Checked 2026-10-26: the [WeasyPrint supported-features documentation](https://doc.courtbouillon.org/weasyprint/stable/api_reference.html) (simple Flexbox and Grid cases with limitations) and the engines listed in the [Gotenberg repository](https://github.com/gotenberg/gotenberg); WeasyPrint's maintainers state the engine runs no JavaScript ([their issue tracker](https://github.com/Kozea/WeasyPrint/issues/817)).

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

### Gotenberg vs LibreOffice

# Gotenberg vs LibreOffice

LibreOffice opens HTML through a word-processor import filter, so CSS-heavy pages degrade: layouts collapse and styles drop. Chromium renders the page as a browser does. Gotenberg also bundles LibreOffice for office documents, so the real rule is by input type, not by winner.

## Gotenberg vs LibreOffice: what each one is

**Gotenberg**: REST API in one Docker container, MIT license, bundling LibreOffice for office files alongside Chromium. Engine: headless Chromium (plus LibreOffice for office files). Ops: you run the container: upgrades, scaling, retries. Cost: free license, your infrastructure. Best for web apps, SPAs, anything client-rendered.

**LibreOffice**: open-source office suite with a headless document converter. Engine: LibreOffice's word-processor import for HTML; native rendering for office formats. Ops: a heavy suite installed on your host or container. Cost: free license, your infrastructure. Best for office documents: .docx, .xlsx, .pptx to PDF.

## Gotenberg vs LibreOffice at a glance

| Aspect | Gotenberg | LibreOffice |
| --- | --- | --- |
| Engine | Chromium | office import filters |
| Hosting | your Docker | your host |
| JavaScript | executed | not executed (HTML import drops it) |
| Web fonts | loaded from CSS | only if installed |
| Modern CSS | yes | largely unsupported on HTML import |
| Ops burden | container lifecycle | suite installation and updates |
| Cost basis | infra only | infra only |
| Best for | web pages | office files |

HTML is a browser job; office files are a LibreOffice job, and Gotenberg ships both engines. Documents authored for print-CSS paged media are the third job, and those belong to a print-CSS engine: the [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint) page maps that split.

## The verdict: when each one wins

**Gotenberg wins:** HTML and URL conversions, where a browser engine is the thing that renders the page.

**LibreOffice wins:** office documents, where its import filters are the point.

**Neither wins:** hosted alternatives exist for both jobs (webcapture covers the HTML side, per-document pricing at [our published pricing](https://webcapture.io/pricing)).

## How this connects

If your inputs are office files, the container's LibreOffice route is the point; for HTML the browser engine inside it does the work. The [migration hub](https://webcapture.io/wkhtmltopdf-alternatives) is where teams leaving the archived binary start.

Wider than this pair: [the capability matrix](https://webcapture.io/compare) reads eight vendors across fourteen rows, each cell sourced from the vendor's own docs.

Going deeper: the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices running the container yourself against the hosted rates, [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) covers why HTML needs a browser engine at all, and [the webcapture product page](https://webcapture.io/html-to-pdf-api) states the hosted HTML route in one page.

## Frequently asked questions

### Can LibreOffice convert HTML to PDF faithfully?

Not for CSS-heavy pages. LibreOffice opens HTML through a word-processor import filter, so layouts collapse and styles drop; a browser engine renders the page as a browser does.

### What is Gotenberg?

An MIT-licensed Docker container that exposes Chromium, and LibreOffice for office files, behind a REST API, so any language can convert without installing a browser.

### Which one should I use for .docx to PDF?

LibreOffice's converter, which Gotenberg also bundles: office documents are import-filter territory, and the suite's native rendering is the point there.

---

More comparisons: [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), [Puppeteer vs wkhtmltopdf](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf), [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift).

## Method

Checked 2026-09-11: the [Gotenberg repository](https://github.com/gotenberg/gotenberg) documents its bundled LibreOffice route alongside Chromium; [LibreOffice](https://www.libreoffice.org)'s HTML import limits are long-standing. The answer was corrected on 2026-10-26 to scope what each engine can reproduce.

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

### Puppeteer vs wkhtmltopdf

# Puppeteer vs wkhtmltopdf

wkhtmltopdf was archived on Jan 2, 2023 and has received no fixes since. Puppeteer drives a maintained Chromium: modern CSS actually applies, JavaScript executes, web fonts load from the page. The comparison ends at support; the migration is mostly deleting the workarounds the old engine forced on you.

## Puppeteer vs wkhtmltopdf: what each one is

**Puppeteer**: Node.js library that drives Chrome. Engine: headless Chromium via the Chrome DevTools Protocol. Ops: your code starts, pools, and crashes with the browser. Cost: free library, your infrastructure. Best for everything still being maintained.

**wkhtmltopdf**: the archived Qt WebKit command-line converter. Engine: WebKit-era, frozen in 2023. Ops: a native binary you build or fetch, no longer receiving fixes. Cost: free, unmaintained. Best for legacy environments pinned to very old Linux where a Chrome dependency cannot fit, and even there, plan the exit.

## Puppeteer vs wkhtmltopdf at a glance

| Aspect | Puppeteer | wkhtmltopdf |
| --- | --- | --- |
| Engine | Chromium | WebKit (archived) |
| Hosting | your Node process | your host's binary |
| JavaScript | executed | partial, era-frozen |
| Web fonts | loaded from CSS | installed on the server |
| Modern CSS | yes | no reliable flexbox or grid |
| Ops burden | browser lifecycle in code | unmaintained binary drift |
| Cost basis | infra only | free, unsupported |
| Best for | maintained products | nothing new |

A maintained engine against an archived one; support decides this comparison.

## The verdict: when each one wins

**Puppeteer wins:** everything still being maintained; for flag-by-flag migration see the [Python migration guide](https://webcapture.io/wkhtmltopdf-alternatives/python) and its sibling languages.

**wkhtmltopdf wins:** only legacy environments pinned to old Linux that cannot carry a Chrome dependency, and even there, plan the exit.

**Neither wins:** anyone starting fresh has no reason to adopt an archived binary; if operating Chromium yourself is the concern, that is the hosted route (webcapture, same Chromium as Puppeteer, per-document pricing at [our published pricing](https://webcapture.io/pricing)).

## How this connects

Migrating either way, the flag table on the [alternatives hub](https://webcapture.io/wkhtmltopdf-alternatives) maps each old flag to its browser equivalent.

Wider than this pair: [the capability matrix](https://webcapture.io/compare) reads eight vendors across fourteen rows, each cell sourced from the vendor's own docs.

Going deeper: [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) walks the breakages the old engine causes and the settings that fix them, the [open-source comparison](https://webcapture.io/guides/open-source-html-to-pdf-api) prices the free routes against hosted ones, and [the webcapture product page](https://webcapture.io/html-to-pdf-api) states engine, pricing, and a curl quick start.

## Frequently asked questions

### Should I migrate from wkhtmltopdf to Puppeteer?

Yes, if the output still matters. wkhtmltopdf was archived on Jan 2, 2023 and its WebKit engine predates flexbox and grid; Puppeteer drives a maintained Chromium that applies both. The side-by-side table above is the short version.

### Is Puppeteer a drop-in replacement for wkhtmltopdf?

Close to a rename, not a drop-in: page size, orientation, margins, and print background each map to one option on the print call. What changes is the engine underneath, so tuned templates deserve a visual check before cutover.

### What breaks first in a wkhtmltopdf migration?

Two things: the smart-shrinking workarounds, which have no counterpart in Chromium and should be deleted rather than ported, and server-installed fonts, which the page's own web fonts replace. The formatting guide covers both.

---

More comparisons: [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice), [webcapture vs PDFShift](https://webcapture.io/vs/pdfshift).

## Method

Checked 2026-09-11: the [wkhtmltopdf repository](https://github.com/wkhtmltopdf/wkhtmltopdf) archive banner (Jan 2, 2023, read-only) and current [Puppeteer documentation](https://pptr.dev).

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

### webcapture vs PDFShift

# webcapture vs PDFShift

$19 a month renders 5,000 pages; PDFShift's Boost is $24 for 2,500 credits and its Growth tier for the same 5,000 documents is $39 (re-checked 2026-10-03). Both render with disclosed Chromium, so the printed output is usually close, and a fixture settles the version, wait, font, and post-processing edge cases. The bills differ. webcapture bills one render per document. PDFShift bills one credit per 5Mb of output. The formats, timeouts, and async contract are on the same sheet below.

## webcapture vs PDFShift: what each one is

**webcapture**: hosted HTML to PDF and extraction API, Chromium disclosed, Pro at $19 a month. Engine: headless Chromium, disclosed. Ops: none: hosted, keys self-serve. Cost: one render is one billable document, any size; quota answers 429 and never bills overage. Best for teams billed per document that want Markdown output and per-call dark mode.

**PDFShift**: hosted PDF and image API operating since 2018, Chromium disclosed, tiers from $24 a month. Engine: headless Chromium, disclosed. Ops: none: hosted, self-serve. Cost: credits: one per 5Mb of output, Boost $24 for 2,500 up to Business $99 for 25,000, opt-in overage on top. Best for teams that need templates, S3 delivery, HIPAA processing, or PDF encryption.

## webcapture vs PDFShift at a glance

| Aspect | webcapture | PDFShift |
| --- | --- | --- |
| Price at 5,000 a month | $19 (Pro): 5,000 renders, 0.38 cents a render | $39 (Growth) for 5,000 credits; $24 (Boost) covers 2,500 (re-checked 2026-10-03) |
| Published volume tiers | Pro $19/5,000; Scale $69/35,000 | Business $99/25,000 (re-checked 2026-10-03) |
| The billable unit | one rendered document, any size | one credit per 5Mb of generated output (their live pricing API, re-checked 2026-10-03) |
| A 14Mb document costs | 1 render: 0.38 cents | 3 credits: 2.88 cents at Boost's $24 for 2,500 |
| Past the monthly quota | 429 with used, limit, and resetDate; no overage is ever billed | opt-in overage on Boost lists at 3 cents a credit, 3.1x the in-plan rate (re-checked 2026-10-24) |
| Output formats | pdf, png, jpeg, webp, plus Markdown | pdf, png, jpeg, webp (their OpenAPI spec, re-checked 2026-10-03) |
| Color scheme | colorScheme: light, dark, or site, per call | no first-class parameter; css and javascript injection are the workaround |
| Render timeout | 60 seconds on every plan | 30 seconds on the free tier, 100 seconds on paid (re-checked 2026-10-03) |
| Async mode | ?wait=false returns a 202 ticket with poll and download URLs; webhooks are HMAC-signed on Pro and Scale | async requires a webhook, and the spec has no polling endpoint (re-checked 2026-10-03) |

PDFShift cells re-checked 2026-10-03 against the live pricing and OpenAPI spec; Boost overage re-checked 2026-10-24. Ours is the published rate card.

## Feature by feature

| Capability | webcapture | PDFShift |
| --- | --- | --- |
| Output formats | ✓ | ✓ |
| Markdown | ✓ | ✗ |
| Tagged PDF | ✓ | ✗ |
| Sync default | ✓ | ✓ |
| Webhooks | ✓ | ✓ |
| Polling | ✓ | ✗ |
| colorScheme | ✓ | ✗ |
| Headers, footers | ✓ | ✓ |
| Margins | ✓ | ✓ |
| Page ranges | ✓ | ✓ |
| Landscape | ✓ | ✓ |
| CSS injection | ✓ | ✓ |
| JavaScript injection | ✓ | ✓ |
| Selector wait | ✓ | ✓ |
| Network-idle wait | ✓ | ✓ |
| Settle | ✓ | ✓ |
| Lazy-image wait | ✓ | ✓ |
| Raise for status | ✓ | ✓ |
| Blank-page removal | ✓ | ✓ |
| Element capture | ✓ | ✓ |
| Region capture | ✓ | ✓ |
| Source fetch | ✓ | ✓ |
| Storage export | ✓ | ✓ |
| Filename | ✓ | ✓ |
| Sandbox | ✓ | ✓ |
| Watermarking | ✓ | ✓ |
| Encryption | ✓ | ✓ |
| Metadata | ✓ | ✓ |
| Templates | ✗ | ✓ |
| Engine pin | ✗ | ✓ |
| HIPAA | ◐ | ✓ |

✓ documented in the vendor's own docs; ✗ absent from the vendor's documented surface; ◐ shipped with a gate. Hover a row for detail. Every mark checked 2026-10-05 against both vendors' published docs and API references. The verdict: when each one wins **webcapture wins:** the 2,500 to 5,000 documents band on price, and three contract points at every band: a document of any size bills once, Markdown output and per-call colorScheme exist, and the 60-second timeout is the same on the free tier as on paid. **PDFShift wins:** everything above 35,000 documents a month: Business is $99 for 25,000 with opt-in overage and tiers run to a million; plus eight years of operation, a review footprint on the software directories, and a wider agent-distribution surface. **Neither wins:** neither side hands you container ops: both are hosted Chromium APIs that hold a plain POST and return the binary, both cap the free tier at 50 a month, and webhooks are not parity: theirs deliver on every plan, ours start at Pro What PDFShift has that we do not The residue that is still theirs alone: stored templates with full CRUD, engine version pinning through their X-Processor-Version header, and the is_gdpr and log_request privacy flags. HIPAA processing with a public BAA is theirs too; our side ships the zero-residue phi rendering mode, gated on the executed BAA the operator program is still completing. S3 delivery is a credential distinction rather than a capability gap: pdfshift's `s3_destination` pushes to your bucket through customer credentials stored on their side, while our `destination` parameter takes a caller-signed presigned PUT URL, so the service never holds your cloud credentials. The feature grid above carries the full mark-by-mark picture, dated, so this paragraph only has to name what remains. Scale is our largest self-serve tier: $69 a month for 35,000 renders, which undercuts the Business plan's $99 for 25,000 on both price and volume. Past 35,000 renders a month the quota returns 429 like every tier; there is no custom tier above Scale. How this connects Switching from pdfshift is not a rewrite at the simple end: POST `/v3/convert/pdf` here accepts their request shape (source as a URL, format as the paper size, landscape, margin, quality, delay, timeout) and answers with raw bytes, with errors in their envelope, so a base URL and key change moves a script that converts URLs. The [step-by-step migration manual](https://webcapture.io/migrate/pdfshift) walks that swap line by line, including the parameters this endpoint answers 422 to; nothing is dropped silently. Wider than this pair: [the capability matrix](https://webcapture.io/compare) reads eight vendors across fourteen rows, each cell sourced from the vendor's own docs. Going deeper: the [pricing page](https://webcapture.io/pricing) is the full rate card with the 429-not-overage policy printed on it, [the free API comparison](https://webcapture.io/free-html-to-pdf-api) prices the $0 route against the same field, and [the webcapture product page](https://webcapture.io/html-to-pdf-api) keeps engine, prices, and a curl quick start on one page. Frequently asked questions Is there a cheaper PDFShift alternative?In the 2,500 to 5,000 documents a month band, yes: webcapture's Pro is $19 for 5,000 renders against Boost's $24 for 2,500 credits, and a document of any size bills once here instead of per 5Mb of output (re-checked 2026-10-03). How do PDFShift credits work?One credit per 5Mb of generated output: a 14Mb document costs 3 credits. Overage, when enabled, lists at 3 cents a credit on Boost, 3.1x the in-plan rate (re-checked 2026-10-24). Does webcapture support async rendering?Yes: ?wait=false returns a 202 ticket with poll and download urls, so you can poll instead of holding the connection. PDFShift's async requires a webhook, with no polling endpoint in its spec. Looking for a PDFShift alternative?Then the switch is smaller than a rewrite: webcapture is the PDFShift alternative that bills one rendered document instead of metering output size, and the [PDFShift migration manual](https://webcapture.io/migrate/pdfshift) maps their request shape onto this API field by field. The table above keeps both engines honest; the manual gets you running today. More comparisons: [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice), [Puppeteer vs wkhtmltopdf](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf). Method Checked 2026-10-03 against [pdfshift.io pricing](https://pdfshift.io/pricing) and the [OpenAPI spec](https://docs.pdfshift.io/openapi.json), with our own numbers from the published rate card; the capability grid re-checked 2026-10-05 against both vendors' documented surfaces, and Boost overage re-checked 2026-10-24. The answer section was corrected on 2026-10-26. Questions or corrections: [hello@webcapture.io](mailto:hello@webcapture.io). 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. [Get an API key](https://webcapture.io/get-started)[Read the docs](https://webcapture.io/docs)

## The verdict: when each one wins

**webcapture wins:** the 2,500 to 5,000 documents band on price, and three contract points at every band: a document of any size bills once, Markdown output and per-call colorScheme exist, and the 60-second timeout is the same on the free tier as on paid.

**PDFShift wins:** everything above 35,000 documents a month: Business is $99 for 25,000 with opt-in overage and tiers run to a million; plus eight years of operation, a review footprint on the software directories, and a wider agent-distribution surface.

**Neither wins:** neither side hands you container ops: both are hosted Chromium APIs that hold a plain POST and return the binary, both cap the free tier at 50 a month, and webhooks are not parity: theirs deliver on every plan, ours start at Pro

## What PDFShift has that we do not

The residue that is still theirs alone: stored templates with full CRUD, engine version pinning through their X-Processor-Version header, and the is_gdpr and log_request privacy flags. HIPAA processing with a public BAA is theirs too; our side ships the zero-residue phi rendering mode, gated on the executed BAA the operator program is still completing. S3 delivery is a credential distinction rather than a capability gap: pdfshift's `s3_destination` pushes to your bucket through customer credentials stored on their side, while our `destination` parameter takes a caller-signed presigned PUT URL, so the service never holds your cloud credentials. The feature grid above carries the full mark-by-mark picture, dated, so this paragraph only has to name what remains.

Scale is our largest self-serve tier: $69 a month for 35,000 renders, which undercuts the Business plan's $99 for 25,000 on both price and volume. Past 35,000 renders a month the quota returns 429 like every tier; there is no custom tier above Scale.

## How this connects

Switching from pdfshift is not a rewrite at the simple end: POST `/v3/convert/pdf` here accepts their request shape (source as a URL, format as the paper size, landscape, margin, quality, delay, timeout) and answers with raw bytes, with errors in their envelope, so a base URL and key change moves a script that converts URLs. The [step-by-step migration manual](https://webcapture.io/migrate/pdfshift) walks that swap line by line, including the parameters this endpoint answers 422 to; nothing is dropped silently.

Wider than this pair: [the capability matrix](https://webcapture.io/compare) reads eight vendors across fourteen rows, each cell sourced from the vendor's own docs.

Going deeper: the [pricing page](https://webcapture.io/pricing) is the full rate card with the 429-not-overage policy printed on it, [the free API comparison](https://webcapture.io/free-html-to-pdf-api) prices the $0 route against the same field, and [the webcapture product page](https://webcapture.io/html-to-pdf-api) keeps engine, prices, and a curl quick start on one page.

## Frequently asked questions

### Is there a cheaper PDFShift alternative?

In the 2,500 to 5,000 documents a month band, yes: webcapture's Pro is $19 for 5,000 renders against Boost's $24 for 2,500 credits, and a document of any size bills once here instead of per 5Mb of output (re-checked 2026-10-03).

### How do PDFShift credits work?

One credit per 5Mb of generated output: a 14Mb document costs 3 credits. Overage, when enabled, lists at 3 cents a credit on Boost, 3.1x the in-plan rate (re-checked 2026-10-24).

### Does webcapture support async rendering?

Yes: ?wait=false returns a 202 ticket with poll and download urls, so you can poll instead of holding the connection. PDFShift's async requires a webhook, with no polling endpoint in its spec.

## Looking for a PDFShift alternative?

Then the switch is smaller than a rewrite: webcapture is the PDFShift alternative that bills one rendered document instead of metering output size, and the [PDFShift migration manual](https://webcapture.io/migrate/pdfshift) maps their request shape onto this API field by field. The table above keeps both engines honest; the manual gets you running today.

---

More comparisons: [Gotenberg vs Puppeteer](https://webcapture.io/vs/gotenberg-vs-puppeteer), [Gotenberg vs WeasyPrint](https://webcapture.io/vs/gotenberg-vs-weasyprint), [Gotenberg vs LibreOffice](https://webcapture.io/vs/gotenberg-vs-libreoffice), [Puppeteer vs wkhtmltopdf](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf).

## Method

Checked 2026-10-03 against [pdfshift.io pricing](https://pdfshift.io/pricing) and the [OpenAPI spec](https://docs.pdfshift.io/openapi.json), with our own numbers from the published rate card; the capability grid re-checked 2026-10-05 against both vendors' documented surfaces, and Boost overage re-checked 2026-10-24. The answer section was corrected on 2026-10-26. Questions or corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

## Automation platform guides

### Automation platforms hub

# Convert a URL to PDF from your automation platform

Eight platforms, one call. Every page here documents the same real request this API serves, POST /v1/convert with a Bearer key and a JSON body carrying a url or raw html, written out in the platform's own step format with the fields the platform actually names.

## The eight platforms

- [n8n](https://webcapture.io/automations/n8n): one HTTP Request node POSTs the url, a second node GETs the receipt's download url with the same credential.
- [Zapier](https://webcapture.io/automations/zapier): a Webhooks by Zapier POST carries the url and header, and a second GET step replays the same header for the bytes.
- [Make](https://webcapture.io/automations/make): the HTTP Make a request module POSTs the JSON body, a Download a file module replays the API key credentials.
- [Bubble](https://webcapture.io/automations/bubble): an API Connector Action carries the private Bearer header; its binary download mapping remains unverified.
- [Clay](https://webcapture.io/automations/clay): an HTTP API enrichment POSTs each row's url and selects receipt fields; the binary download mapping is not executed.
- [FlowMattic](https://webcapture.io/automations/flowmattic): the API module concept supports an outgoing POST; exact editor labels and chained mapping are not verified.
- [Lovable](https://webcapture.io/automations/lovable): a fetch call from a backend function POSTs the url, and the same header fetches the bytes for the UI.
- [Base44](https://webcapture.io/automations/base44): a backend function POSTs the url server-side, and the same header fetches the bytes before they reach the app.

## The shape every page follows

The platform's step holds an outgoing HTTP call; this API's endpoint receives it. The request is a `POST` to `https://webcapture.io/v1/convert` with `Authorization: Bearer wc_your_key` and a JSON body carrying exactly one source: `{"url": "https://example.com/"}` or `{"html": "<html>…</html>"}`. By default the call holds and answers `200` with a receipt: `id`, `bytes`, `attempts`, `remaining`, `sourceUrl`, `sourceType`, and `download`. The `download` value is the GET url for the pdf bytes, and both calls carry your key: the POST renders the document, and the GET serves it only to the key that made it, because the download endpoint is key-scoped (no header answers 401, another key's id answers 404). The pattern on every page is the same two moves: POST the source with your key, then GET the download with the same header. Raw HTML can name a public `baseUrl` for relative assets. A platform whose steps prefer to return quickly can send `?wait=false` and take a `202` ticket carrying `poll` and `download` urls instead. Artifacts are kept for at least seven days (30 on Scale). See [retention by plan](https://webcapture.io/pricing).

## Method

Checked 2026-10-26. Each platform page names the documentation its sample was checked against and links it from its own method footer; no field is written here that the platform's own surface does not name. Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### n8n

# Convert HTML to PDF from n8n

Wire n8n's HTTP Request node to POST a url to /v1/convert with a Bearer key, then fetch the download url the receipt returns.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. n8n changes the wrapper, not those values or the authenticated download.

1. Add an **HTTP Request** node. Set **Method** to `POST` and **URL** to your serving origin plus `/v1/convert`.
2. Set **Authentication** to *Generic Credential Type*, then *Header Auth*, and save a credential with name `Authorization` and value `Bearer wc_your_key`. The key lives in the stored credential, not in the node.
3. Turn **Send Body** on, set **Body Content Type** to `JSON`, and put the request in the **JSON** field: `{"url": "https://example.com/"}`.
4. Run the node once. The output is the sync receipt: `id`, `bytes`, `attempts`, `remaining`, `sourceUrl`, `sourceType`, and `download`.
5. Add a second HTTP Request node: Method `GET`, URL `{{ $json.download }}`, carrying the same Header Auth credential as the first node. The download endpoint is key-scoped: without the header it answers 401, and another key's id answers 404. Its output is the pdf bytes.

## The full request

```
Method:  POST
URL:     https://webcapture.io/v1/convert
Authentication: Header Auth
  Name:  Authorization
  Value: Bearer wc_your_key
Send Body: on, Body Content Type: JSON
JSON:    {"url": "https://example.com/"}
```

The response is a JSON receipt, not the bytes. Point a second HTTP Request node at the `download` value with the same Header Auth credential as the first, and the finished document arrives in the workflow's binary output, one render per document counted against the key's quota. The download endpoint is key-scoped: it answers 401 without the Authorization header and 404 for another key's id, so the download url is not a shareable link. If an earlier node already builds an invoice or report as HTML, send `{"html": "<html>…</html>"}` instead of `url`; add `baseUrl` when relative images or stylesheets need a public origin.

## When it fails, and what it means

When the n8n step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The n8n step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP; over the window the answer is `429` with a `retryAfterSec` field. The account's monthly quota is separate: an exhausted quota answers `429` carrying `used`, `limit`, `resetDate`, and `upgradeUrl`, and every receipt's `remaining` field shows the count after that render. If the workflow's execution timeout is tight, POST with `?wait=false`: the `202` ticket carries `poll` and `download` urls a later node can fetch.

## Related

- [n8n documentation](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [Zapier](https://webcapture.io/automations/zapier) and [Base44](https://webcapture.io/automations/base44); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against n8n: The node's parameter names follow n8n's HTTP Request node documentation. Source: [https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire n8n.

### Zapier

# Convert HTML to PDF from Zapier

Send the POST through Webhooks by Zapier with the Bearer header, map the receipt's download field, and the document url lands in the Zap.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. Zapier changes the wrapper, not those values or the authenticated download.

1. Add an action and choose **Webhooks by Zapier** with the action event **POST**.
2. Set **URL** to your serving origin plus `/v1/convert`, and **Payload Type** to `json`.
3. Under **Data**, add one row: key `url`, value the page address to render, for example `https://example.com/`.
4. Under **Headers**, add one row: key `Authorization`, value `Bearer wc_your_key`.
5. Test the step. The response is the sync receipt, and Zapier's field finder exposes `download` as a value the next action can map.
6. Add a second Webhooks by Zapier action, event **GET**, with the download url as its URL and the same Authorization header row as the POST step.

## The full request

```
App:          Webhooks by Zapier
Action event: POST
URL:          https://webcapture.io/v1/convert
Payload Type: json
Data:         url = https://example.com/
Headers:      Authorization = Bearer wc_your_key
```

The test response carries the receipt fields, and `download` is the url to the finished document. The second GET action replays the same Authorization header, because the download endpoint is key-scoped: it answers 401 without the header and 404 for another key's id, so the url alone is worth nothing to a step or a recipient that does not hold your key.

## When it fails, and what it means

When the Zapier step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The Zapier step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

This API's convert endpoint rate limits at 120 requests a minute per client IP, answering `429` with `retryAfterSec` when the window fills. The monthly quota is per account: an exhausted quota answers `429` with `used`, `limit`, `resetDate`, and `upgradeUrl`, and the receipt's `remaining` field counts down every render. Long pages that would sit past a Zap step's patience can POST with `?wait=false` and take the `202` ticket's `download` url in a later step.

## Related

- [Zapier documentation](https://zapier.com/apps/webhook): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [Make](https://webcapture.io/automations/make) and [n8n](https://webcapture.io/automations/n8n); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against Zapier: The action event and field names follow the Webhooks by Zapier app's POST action as listed on zapier.com/apps/webhook; Zapier's help center moves articles, so the app page is the stable citation. Source: [https://zapier.com/apps/webhook](https://zapier.com/apps/webhook). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire Zapier.

### Make

# Convert HTML to PDF from Make

Use Make's HTTP Make a request module with a JSON string body and API key credentials, then fetch the download url.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. Make changes the wrapper, not those values or the authenticated download.

1. Add the **HTTP > Make a request** module.
2. Set **URL** to your serving origin plus `/v1/convert` and **Method** to `POST`.
3. Under **Credentials**, create an API key keychain configured to send `Authorization` with value `Bearer wc_your_key`. Keep the secret in the credential rather than a module Headers item; the cited docs do not establish labels for these credential entries.
4. Set **Body content type** to `application/JSON` and **Body input method** to `JSON string`, then enter `{"url": "https://example.com/"}` as the JSON input.
5. Run the module once: it parses the JSON receipt, and `download` becomes a mappable output field.
6. Follow with **HTTP > Download a file**, URL set to that `download` field and the same API key credential. The download endpoint is key-scoped: without the header it answers 401, and another key's id answers 404.

## The full request

```
Module: HTTP > Make a request
URL: https://webcapture.io/v1/convert
Method: POST
Authentication type: API key
Credentials: webcapture keychain
Credential sends Authorization = Bearer wc_your_key
Body content type: application/JSON
Body input method: JSON string
JSON input: {"url": "https://example.com/"}
Next module: HTTP > Download a file (same Credentials)
```

The receipt's `download` field maps into **HTTP > Download a file**. Its download step is URL set to that `download` field and the same API key credential carrying the `Authorization` header; the bare url answers 401 to any module or service that tries it without the key.

## When it fails, and what it means

When the Make step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The Make step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP; a full window answers `429` with `retryAfterSec`. Quota is monthly and per account: exhausted, the endpoint answers `429` carrying `used`, `limit`, `resetDate`, and `upgradeUrl`, while the receipt's `remaining` field reports the count after each render. For a page that renders past the module's comfortable wait, send `?wait=false` and take the `202` ticket's `download` url in a later module.

## Related

- [Make documentation](https://apps.make.com/http): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [Bubble](https://webcapture.io/automations/bubble) and [Zapier](https://webcapture.io/automations/zapier); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against Make: Only labels established by Make v4 HTTP app documentation are quoted: Make a request, Download a file, Credentials, Body content type application/JSON, and Body input method JSON string. Credential-entry configuration and JSON input are described without inventing editor labels. Source: [https://apps.make.com/http](https://apps.make.com/http). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire Make.

### Bubble

# Convert HTML to PDF from Bubble

Add the POST as an Action in Bubble's API Connector with a private Authorization header, and keep the binary GET unverified.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. Bubble changes the wrapper, not those values or the authenticated download.

1. Install the **API Connector** plugin, add an API (name it webcapture), and add a call.
2. **Call name**: ConvertPage. Set **Use as** to *Action*; Bubble runs actions and initialization through Bubble rather than exposing private headers client-side.
3. **Method**: `POST`. **URL**: your serving origin plus `/v1/convert`.
4. Under **Headers**, add key `Authorization` with value `Bearer wc_your_key` and tick *Private*, which stops the header information being visible client-side.
5. Set **Body** to `{"url": "https://example.com/"}` with **Body type** `JSON`.
6. Click **Initialize call**: Bubble stores the response schema, and the receipt's `download` field becomes a result your workflow actions can read. Do not assume the cited documentation supports a binary response.

## The full request

```
Call name: ConvertPage
Use as: Action
Method: POST
URL: https://webcapture.io/v1/convert
Headers: Authorization = Bearer wc_your_key (Private)
Body: {"url": "https://example.com/"}
Body type: JSON
Initialize call
```

The call's result exposes `download` as text. The proposed download step is a second API Connector call (Method `GET`, the same private `Authorization` header), whose binary handling is unverified: the binary GET is unverified in Bubble's cited setup page, which documents response fields rather than binary handling. Test that path locally before relying on it. A bare browser link answers 401, and another key's id answers 404.

## When it fails, and what it means

When the Bubble step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The Bubble step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP, answering `429` with `retryAfterSec` on a full window. The monthly quota is per account: exhausted, the answer is `429` with `used`, `limit`, `resetDate`, and `upgradeUrl`, and each receipt's `remaining` field shows the count after the render. Bubble workflows that would block on a slow page can send `?wait=false` and read the `202` ticket's `download` url from a later action.

## Related

- [Bubble documentation](https://manual.bubble.io/core-resources/api/the-api-connector/adding-calls): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [Clay](https://webcapture.io/automations/clay) and [Make](https://webcapture.io/automations/make); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against Bubble: The call labels follow Bubble's API Connector documentation. Use as: Action keeps the call on the server-side workflow surface, and Private hides header information client-side. The source does not document binary GET handling, so that mapping is explicitly unverified. Source: [https://manual.bubble.io/core-resources/api/the-api-connector/adding-calls](https://manual.bubble.io/core-resources/api/the-api-connector/adding-calls). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire Bubble.

### Clay

# Convert HTML to PDF from Clay

Add an HTTP API enrichment that POSTs each row's url and selects receipt field paths; binary download mapping is not executed.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. Clay changes the wrapper, not those values or the authenticated download.

1. In a Clay table, click **Add enrichment** and search for **HTTP API**.
2. Set **HTTP method** to `POST` and **API endpoint URL** to your serving origin plus `/v1/convert`.
3. Under **Header fields**, add key `Authorization` with value `Bearer wc_your_key`.
4. Set **JSON body** to `{"url": "https://example.com/"}`, interpolating the column that holds the page address where the example's url sits.
5. Use **Field paths to return** to select receipt values such as `download`, `bytes`, and `remaining`. Clay's cited page does not document binary-response behavior, so the two-column download mapping below is not executed and must be validated locally before use.

## The full request

```
Enrichment: HTTP API
HTTP method: POST
API endpoint URL: https://webcapture.io/v1/convert
Header fields: Authorization = Bearer wc_your_key
JSON body: {"url": "{{Page URL column}}"}
Field paths to return: download, bytes, remaining
Second-column binary GET: not executed
```

The first column returns receipt values selected with **Field paths to return**. The proposed second column is (HTTP method `GET`, same `Authorization` header), a two-column mapping that is not executed; the source does not document binary responses. The bare url answers 401, and another key's id answers 404.

## When it fails, and what it means

When the Clay step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The Clay step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP; a full window answers `429` with `retryAfterSec`. The monthly quota is per account: exhausted, the endpoint answers `429` carrying `used`, `limit`, `resetDate`, and `upgradeUrl`, and the receipt's `remaining` field counts each render. Clay columns that run a whole table at once spend that budget quickly, so stage large tables rather than firing every row at once; `?wait=false` hands back a `202` ticket whose `download` url a later column can fetch.

## Related

- [Clay documentation](https://university.clay.com/docs/http-api-integration-overview): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [FlowMattic](https://webcapture.io/automations/flowmattic) and [Bubble](https://webcapture.io/automations/bubble); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against Clay: The configuration labels HTTP method, API endpoint URL, JSON body, Header fields, and Field paths to return follow Clay's HTTP API integration overview. It does not document binary responses, so the second-column mapping is marked not executed rather than asserted. Source: [https://university.clay.com/docs/http-api-integration-overview](https://university.clay.com/docs/http-api-integration-overview). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire Clay.

### FlowMattic

# Convert HTML to PDF from FlowMattic

Use FlowMattic's API module concept for a Bearer-authenticated POST; exact labels and chained mapping are not verified.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. FlowMattic changes the wrapper, not those values or the authenticated download.

1. In a workflow, add FlowMattic's API module for an outgoing HTTP call. The exact action label is **not verified** against the cited source.
2. Configure a `POST` to your serving origin plus `/v1/convert`; the source supports the API-module concept but does not establish the current editor's field labels.
3. Configure the request to carry `Authorization` with value `Bearer wc_your_key` and `Content-Type` with value `application/json`.
4. Use the JSON request body `{"url": "https://example.com/"}`. How to insert a workflow variable is **not verified** in the cited source.
5. For the download, configure a second authenticated API call only after verifying locally how your FlowMattic version maps the receipt's `download` value. The endpoint answers 401 without the `Authorization` header and 404 for another key's id.

## The full request

```
Concept: FlowMattic API module (exact action label not verified)
HTTP request:
  POST https://webcapture.io/v1/convert
  Authorization = Bearer wc_your_key
  Content-Type = application/json
  Body = {"url": "https://example.com/"}
Chained download mapping: not verified
```

The API module can represent the outgoing HTTP concept, but the exact editor labels and mapping of `download` into a second task are **not verified**. The proposed second call uses Method `GET`, the receipt's `download` value as the URL, and the same `Authorization` header; the exact action label and chained mapping are **not verified**. Validate that chain locally before use. A url alone answers 401.

## When it fails, and what it means

When the FlowMattic step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The FlowMattic step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP, answering `429` with `retryAfterSec` when the window is full. The monthly quota is per account: exhausted, the answer is `429` with `used`, `limit`, `resetDate`, and `upgradeUrl`, and every receipt's `remaining` field shows the count after that render. A trigger page that renders slowly fits better as `?wait=false`: the `202` ticket's `download` url can ride a later task.

## Related

- [FlowMattic documentation](https://support.flowmattic.com/kb/article/57/use-flowmattic-api-module-for-connecting-applications-via-api): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [Lovable](https://webcapture.io/automations/lovable) and [Clay](https://webcapture.io/automations/clay); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against FlowMattic: The cited article supports only the API module concept here. Exact action and request-field labels, plus chained response mapping, are not verified and are not presented as current editor facts. Source: [https://support.flowmattic.com/kb/article/57/use-flowmattic-api-module-for-connecting-applications-via-api](https://support.flowmattic.com/kb/article/57/use-flowmattic-api-module-for-connecting-applications-via-api). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire FlowMattic.

### Lovable

# Convert HTML to PDF from Lovable

Keep the key in Lovable's secrets, call /v1/convert from a backend function, and fetch the download url with the same Bearer header.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. Lovable changes the wrapper, not those values or the authenticated download.

1. Put the API key in the project's secrets (Lovable's secrets manager), not in client code.
2. Ask Lovable for a backend function that calls the convert endpoint, or add the fetch to the generated code yourself; the function shape is the same either way.
3. The function POSTs to your serving origin plus `/v1/convert` with the `Authorization` header and the JSON body, then reads the receipt's `download` url.
4. The function fetches that download url with the same `Authorization` header (the endpoint is key-scoped: 401 without it, 404 for another key's id) and hands the bytes, not a bare url, to the UI's save or share action.

## The full request

```
const POLL_LIMIT = 60;

async function readBytes(response) {
  const file = response;
  if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
  return new Uint8Array(await file.arrayBuffer());
}

export async function convertPage(url) {
  const auth = { Authorization: `Bearer ${process.env.WEBCAPTURE_KEY || "wc_your_key"}` };
  const res = await fetch("https://webcapture.io/v1/convert", {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ url }),
  });
  if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
  const receipt = await res.json();
  let job = receipt;
  for (let polls = 0; job.status && job.status !== "ready" && polls  setTimeout(resolve, 1000));
    const status = await fetch(job.poll, { headers: auth });
    if (!status.ok) throw new Error(`poll failed: ${status.status} ${await status.text()}`);
    job = await status.json();
  }
  if (job.status && job.status !== "ready") throw new Error("poll limit exhausted");
  // receipt.download is key-scoped: the GET needs the same header
  const file = await fetch(job.download, { headers: auth });
  if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
  const bytes = await readBytes(file);
  console.log("pdf response", {
    contentType: file.headers.get("content-type"),
    contentLength: file.headers.get("content-length"),
    bytes: bytes.byteLength,
  });
  return bytes; // hand these bytes to the UI save or share handler
}
```

The first response is the receipt, not the bytes: `id`, `bytes`, `attempts`, `remaining`, `sourceUrl`, `sourceType`, and `download`. Fetching `download` returns the pdf itself, and that fetch carries the same `Authorization` header: the download endpoint is key-scoped, answering 401 without the header and 404 for another key's id, so the url is not a shareable link. Keeping both calls in a backend function keeps the key server-side and away from the browser bundle.

## When it fails, and what it means

When the Lovable step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The Lovable step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP; a full window answers `429` with `retryAfterSec`. The monthly quota is per account: exhausted, the endpoint answers `429` carrying `used`, `limit`, `resetDate`, and `upgradeUrl`, and the receipt's `remaining` field reports the count after each render. A browser-driven call that must not hold the tab open can send `?wait=false` and take the `202` ticket's `poll` and `download` urls.

## Related

- [Lovable documentation](https://docs.lovable.dev/integrations/any-api): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [Base44](https://webcapture.io/automations/base44) and [FlowMattic](https://webcapture.io/automations/flowmattic); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against Lovable: The snippet follows Lovable's integration documentation for calling external APIs with fetch and storing credentials in the secrets manager. Source: [https://docs.lovable.dev/integrations/any-api](https://docs.lovable.dev/integrations/any-api). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire Lovable.

### Base44

# Convert HTML to PDF from Base44

Call /v1/convert from a Base44 backend function with the Bearer header, then fetch the download url with the same header.

## Wire it up

The shared request is `Authorization: Bearer wc_your_key` and `{"url": "https://example.com/"}`. Base44 changes the wrapper, not those values or the authenticated download.

1. Store the API key in the project's secret or environment storage, not in app code.
2. Add a backend function that performs the POST; backend functions are the supported route for external HTTP calls, and they keep the key off the client.
3. The function targets your serving origin plus `/v1/convert`, sends the `Authorization` header and the JSON body, and reads the receipt's `download` url.
4. Expose the function to the app. It reads the downloaded response into bytes; this example chooses base64 text as a serializable return for the UI to decode, not a universal Base44 requirement. The download endpoint is key-scoped, so a bare url handed to the browser answers 401.

## The full request

```
const POLL_LIMIT = 60;

async function readBytes(response) {
  const file = response;
  if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
  return new Uint8Array(await file.arrayBuffer());
}

export async function convertPage(args) {
  const auth = { Authorization: `Bearer ${secrets.WEBCAPTURE_KEY || "wc_your_key"}` };
  const res = await fetch("https://webcapture.io/v1/convert", {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ url: args.url }),
  });
  if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
  const receipt = await res.json();
  let job = receipt;
  for (let polls = 0; job.status && job.status !== "ready" && polls  setTimeout(resolve, 1000));
    const status = await fetch(job.poll, { headers: auth });
    if (!status.ok) throw new Error(`poll failed: ${status.status} ${await status.text()}`);
    job = await status.json();
  }
  if (job.status && job.status !== "ready") throw new Error("poll limit exhausted");
  // key-scoped: the download GET takes the same Authorization header
  const file = await fetch(job.download, { headers: auth });
  if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
  const bytes = await readBytes(file);
  console.log("pdf response", {
    contentType: file.headers.get("content-type"),
    contentLength: file.headers.get("content-length"),
    bytes: bytes.byteLength,
  });
  // Implementation choice for this snippet: return base64 text so the
  // function result is serializable without assuming Base44 accepts bytes.
  // Buffer is available in the Node-compatible runtime used for this example.
  return Buffer.from(bytes).toString("base64");
}
```

The response is the receipt JSON, not the artifact. The function checks both response statuses and reads the download with `arrayBuffer()`. Returning base64 text and using Buffer are implementation choices for this Node-compatible example, not claims about universal Base44 behavior. The download url is key-scoped: fetching it takes the same `Authorization` header as the POST, it answers 401 without the header, and another key's id answers 404.

## When it fails, and what it means

When the Base44 step fails, the status code tells you which side to fix:

- `401`: the Authorization header is mis-shaped. The name is `Authorization` and the value is `Bearer wc_your_key`; the full header line pasted into a value field double-prefixes the scheme and fails.
- `404` on the download GET: the id belongs to another key. Downloads are key-scoped, so the fetch has to carry the key that made the render.
- `429` whose body carries a reset date: the month's quota is gone. Retry after the reset date or move up a tier.
- Target errors: the page being rendered must be a public URL the renderer can fetch. The [error code table](https://webcapture.io/docs#errors) lists every status and its next step.

## Variations

The Base44 step does not have to hold the connection while the page renders. Add `?wait=false` to the convert URL and the call answers `202` right away with a ticket carrying `poll` and `download` urls; a later step polls the status endpoint and fetches the document once the render is done.

When the workflow already holds the page as HTML (a template an earlier step filled in, say), POST `{"html": "<html>…</html>"}` instead of `url`, and add `baseUrl` when relative images or stylesheets need a public origin to resolve against. The receipt is the same either way.

## Limits

The convert endpoint rate limits at 120 requests a minute per client IP; a full window answers `429` with `retryAfterSec`. The monthly quota is per account: exhausted, the endpoint answers `429` carrying `used`, `limit`, `resetDate`, and `upgradeUrl`, and each receipt's `remaining` field shows the count after the render. Serverless functions with short execution ceilings fit `?wait=false`: the `202` ticket's `download` url stays valid for a later authenticated fetch.

## Related

- [Base44 documentation](https://doc-sdk.base44.app): the integration surface this page checked against.
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the same render in a browser before you wire the step.
- [The automations hub](https://webcapture.io/automations): the same call translated for the other platforms.

Other platforms: [n8n](https://webcapture.io/automations/n8n) and [Lovable](https://webcapture.io/automations/lovable); the [automation hub](https://webcapture.io/automations) holds all eight.

## Method

Checked 2026-10-26 against Base44: The exported backend-function route follows Base44's SDK documentation. Buffer and base64 serialization are explicit implementation choices for this Node-compatible example, not sourced claims that every Base44 runtime or function response requires them. Source: [https://doc-sdk.base44.app](https://doc-sdk.base44.app). Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

[Get a free key](https://webcapture.io/get-started) or [read the API reference](https://webcapture.io/docs) to wire Base44.

## Recipe guides

### Recipes hub

# URL to PDF recipes for curl, Python, Node, and TypeScript

Fourteen complete programs, one endpoint. Each recipe is one runnable file in the language you already have, and every one of them does the same two calls: POST the url to /v1/convert with your key, then GET the receipt's download url with the same header.

| Recipe | curl | Python | Node | TypeScript |
| --- | --- | --- | --- | --- |
| Convert from a URL | [curl](https://webcapture.io/guides/recipes/curl-convert-from-url) | [requests](https://webcapture.io/guides/recipes/python-convert-from-url) | [fetch](https://webcapture.io/guides/recipes/node-convert-from-url) | [fetch](https://webcapture.io/guides/recipes/typescript-convert-from-url) |
| Print only some pages | [curl](https://webcapture.io/guides/recipes/curl-page-ranges) |  |  |  |
| Name the download file | [curl](https://webcapture.io/guides/recipes/curl-filename) |  |  |  |
| Skip the wait | [curl](https://webcapture.io/guides/recipes/curl-poll-async) | [requests](https://webcapture.io/guides/recipes/python-poll-async) | [fetch](https://webcapture.io/guides/recipes/node-poll-async) |  |
| Wait for the page |  | [requests](https://webcapture.io/guides/recipes/python-wait-for-page) |  |  |
| Save the document |  | [requests](https://webcapture.io/guides/recipes/python-save-to-file) |  |  |
| Fetch the bytes |  |  | [fetch](https://webcapture.io/guides/recipes/node-download-with-auth) |  |
| Dark mode |  |  |  | [fetch](https://webcapture.io/guides/recipes/typescript-dark-mode) |
| Markdown output |  |  |  | [fetch](https://webcapture.io/guides/recipes/typescript-markdown) |

A blank cell is a recipe not written yet, and this hub lists only what ships.

## The shape every recipe follows

The request is a `POST` to `https://webcapture.io/v1/convert` with `Authorization: Bearer wc_your_key` and a JSON body of at least `{"url": "https://example.com/"}`. By default the call holds and answers `200` with a receipt: `id`, `bytes`, `attempts`, `remaining`, `sourceUrl`, `sourceType`, and `download`. The `download` value is the GET url for the finished document, and both calls carry your key: the POST renders the document, and the GET serves it only to the key that made it, so a url pasted into a browser answers 401. The same POST accepts the body fields each recipe teaches, all listed on the [docs page](https://webcapture.io/docs#convert-fields), and `?wait=false` swaps the hold for a `202` ticket carrying `poll` and `download` urls. Artifacts are kept for at least seven days (30 on Scale).

Driving this from an automation platform instead? The [automation guides](https://webcapture.io/automations) document the same call in eight tools' step formats.

## Method

Checked 2026-10-05 against a running boot of this service: each sample was sent, and the receipt shapes, the 401 download answer, and the async ticket in these pages are what the endpoint answered. The two async-poll records were added 2026-10-06 and restate that ticket contract; the convert endpoint is unchanged since the boot. Every sample was then rewritten on 2026-10-26 so response errors surface with the endpoint's message and polling is bounded and ends on a terminal status; the rewrite changes caller-side handling only, not the endpoint answers described above. Corrections: [hello@webcapture.io](mailto:hello@webcapture.io).

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

### curl: convert from a URL

# Convert a URL to PDF with curl

Two curl calls, one document: the POST hands the endpoint a url and comes back with a receipt, the GET trades the receipt's download url for the pdf bytes.

The convert endpoint is synchronous by default: the call holds until the render finishes, then answers 200 with a JSON receipt. The receipt is metadata, not the document; the bytes live behind the receipt's download url, and that url only answers to your key.

## The recipe

```
ORIGIN="https://webcapture.io"

RECEIPT=$(curl -sS -f -X POST "$ORIGIN/v1/convert" \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}')
echo "$RECEIPT"
# {"id": "cnv_9f2c01ab", "bytes": 158120, "attempts": 1,
#  "remaining": 49, "sourceUrl": "https://example.com/", "sourceType": "url",
#  "download": ".../v1/conversions/cnv_9f2c01ab.pdf"}

ID=$(printf '%s' "$RECEIPT" | sed -E 's/.*"id": *"([^"]+)".*/\1/')
case "$ID" in cnv_*) ;; *) echo "convert failed: $RECEIPT" >&2; exit 1 ;; esac

# fetch the bytes with the same header:
curl -sS -f -H "Authorization: Bearer wc_your_key" \
  -o page.pdf \
  "$ORIGIN/v1/conversions/$ID.pdf"
```

The first call renders example.com and answers with the receipt: id, bytes, attempts, remaining (renders left this month), sourceUrl, sourceType, and download. The second call fetches that download url and writes page.pdf. The -f flag on every curl call makes a non-success HTTP status fail before a receipt or artifact is used. One render, one document against the key's quota, and the receipt's remaining field tells you where the month stands.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL.
- `format`: pdf is the default; png, jpeg, webp, and markdown exist too.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this curl recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Python: convert from a URL](https://webcapture.io/guides/recipes/python-convert-from-url), [Node: convert from a URL](https://webcapture.io/guides/recipes/node-convert-from-url), [curl: print page ranges](https://webcapture.io/guides/recipes/curl-page-ranges).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: if the printed page looks nothing like the browser showed it, [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) names the three ways conversion loses layout.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### curl: print page ranges

# Print PDF page ranges with curl

A long page prints to many pdf pages, and pageRanges picks which of them ship: one body field between the POST and the same two-call pattern.

pageRanges counts the printed pdf pages, not screenfuls: a long report that renders to twelve A4 pages can ship as its first three plus the table on page seven. The field applies to pdf output, which is the default format, so the body below sends nothing else.

## The recipe

```
ORIGIN="https://webcapture.io"

RECEIPT=$(curl -sS -f -X POST "$ORIGIN/v1/convert" \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/", "pageRanges": "1-3,7"}')
echo "$RECEIPT"
# {"id": "cnv_77d1e0aa", "bytes": 41207, "attempts": 1,
#  "remaining": 48, "sourceUrl": "https://example.com/", "sourceType": "url",
#  "download": ".../v1/conversions/cnv_77d1e0aa.pdf"}

ID=$(printf '%s' "$RECEIPT" | sed -E 's/.*"id": *"([^"]+)".*/\1/')
case "$ID" in cnv_*) ;; *) echo "convert failed: $RECEIPT" >&2; exit 1 ;; esac

curl -sS -f -H "Authorization: Bearer wc_your_key" \
  -o report-excerpt.pdf \
  "$ORIGIN/v1/conversions/$ID.pdf"
```

The render still walks the whole page, so bytes reflects the trimmed document, not the full render. Ranges accept digits, spaces, commas, and hyphens: 1-3,7 is pages one to three plus seven, and a range past the last printed page just stops at the end.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `pageRanges`: digits, spaces, commas, and hyphens, for example 1-3,7; applies to pdf.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this curl recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [curl: convert from a URL](https://webcapture.io/guides/recipes/curl-convert-from-url), [curl: name the file](https://webcapture.io/guides/recipes/curl-filename), [Python: convert from a URL](https://webcapture.io/guides/recipes/python-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [capability matrix](https://webcapture.io/compare) marks which vendors ship page ranges and which bury them.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### curl: name the file

# Name the PDF file with curl

cnv_9f2c01ab.pdf is a database row, not a filename. Send filename in the body and the name rides the receipt and the download's Content-Disposition header.

Without a caller filename the download is named after the conversion id. The filename field renames it at the source: letters, digits, dot, dash, and underscore, up to 100 characters, echoed on the receipt and carried verbatim by the download's Content-Disposition header, where curl's -OJ reads it.

## The recipe

```
ORIGIN="https://webcapture.io"

RECEIPT=$(curl -sS -f -X POST "$ORIGIN/v1/convert" \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/", "filename": "invoice-2026.pdf"}')
echo "$RECEIPT"
# {"id": "cnv_4a7c90be", "bytes": 88210, "attempts": 1,
#  "remaining": 47, "filename": "invoice-2026.pdf",
#  "download": ".../v1/conversions/cnv_4a7c90be.pdf"}

ID=$(printf '%s' "$RECEIPT" | sed -E 's/.*"id": *"([^"]+)".*/\1/')
case "$ID" in cnv_*) ;; *) echo "convert failed: $RECEIPT" >&2; exit 1 ;; esac

# -OJ takes the name from the response's Content-Disposition header:
curl -sS -f -OJ -H "Authorization: Bearer wc_your_key" \
  "$ORIGIN/v1/conversions/$ID.pdf"
# invoice-2026.pdf lands in the working directory
```

The name is stored with the conversion, so every later download of this artifact carries it, and ?disposition=attachment on the download url flips the header's token from inline to attachment without touching the name. The filename is presentation only: the url itself is still the id-based one.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `filename`: 1 to 100 characters of letters, digits, dot, dash or underscore; echoed on the receipt and carried by the download's Content-Disposition header.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this curl recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [curl: convert from a URL](https://webcapture.io/guides/recipes/curl-convert-from-url), [Python: save to a file](https://webcapture.io/guides/recipes/python-save-to-file), [curl: print page ranges](https://webcapture.io/guides/recipes/curl-page-ranges).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [webcapture vs PDFShift comparison](https://webcapture.io/vs/pdfshift) marks filename support on both sides, with the billing contracts beside it.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### curl: poll an async render

# Poll an async PDF render with curl

The same two-call shape as the sync recipe, with a between step: the ticket's poll url is looped until the status leaves queued and rendering.

?wait=false makes the POST answer at once with a 202 ticket: id, status queued, and two urls, poll and download. The loop below asks the poll url once a second; ready means the download url has the document. A failed render ends the loop with the endpoint's error in the poll body.

## The recipe

```
ORIGIN="https://webcapture.io"

TICKET=$(curl -sS -f -X POST "$ORIGIN/v1/convert?wait=false" \
  -H "Authorization: Bearer wc_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}')
echo "$TICKET"
# {"id": "cnv_02b8c9d9", "status": "queued",
#  "poll": ".../v1/conversions/cnv_02b8c9d9",
#  "download": ".../v1/conversions/cnv_02b8c9d9.pdf"}

POLL=$(printf '%s' "$TICKET" | sed -E 's/.*"poll": *"([^"]+)".*/\1/')
DOWNLOAD=$(printf '%s' "$TICKET" | sed -E 's/.*"download": *"([^"]+)".*/\1/')
case "$POLL" in http*) ;; *) echo "convert failed: $TICKET" >&2; exit 1 ;; esac
case "$DOWNLOAD" in http*) ;; *) echo "convert failed: $TICKET" >&2; exit 1 ;; esac

ATTEMPTS=60
ATTEMPT=0
while [ "$ATTEMPT" -lt "$ATTEMPTS" ]; do
  ROW=$(curl -sS -f -H "Authorization: Bearer wc_your_key" "$POLL")
  STATUS=$(printf '%s' "$ROW" | sed -E 's/.*"status": *"([^"]+)".*/\1/')
  [ "$STATUS" = "queued" ] || [ "$STATUS" = "rendering" ] || break
  ATTEMPT=$((ATTEMPT + 1))
  [ "$ATTEMPT" -lt "$ATTEMPTS" ] && sleep 1
done
[ "$STATUS" != "queued" ] && [ "$STATUS" != "rendering" ] || { echo "render polling timed out after 60 attempts" >&2; exit 1; }
[ "$STATUS" = "ready" ] || { echo "render failed: $ROW" >&2; exit 1; }

curl -sS -f -H "Authorization: Bearer wc_your_key" -o page.pdf "$DOWNLOAD"
```

The loop is bounded at 60 poll attempts and exits with render polling timed out after 60 attempts if the job never leaves queued or rendering. The guard after the loop turns a failed render into exit 1 with the endpoint's own message, so a cron job sees the difference between a slow page and a broken one. A ready row adds attempts and bytes to the status, so the caller knows the document's size before it downloads; the one-second pace is the endpoint's guidance for interactive scripts, and batch jobs can poll less often.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL; ?wait=false is a query parameter on the endpoint, not a body field.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this curl recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [curl: convert from a URL](https://webcapture.io/guides/recipes/curl-convert-from-url), [Python: poll an async render](https://webcapture.io/guides/recipes/python-poll-async), [Node: poll an async render](https://webcapture.io/guides/recipes/node-poll-async).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [n8n](https://webcapture.io/automations/n8n), [Zapier](https://webcapture.io/automations/zapier), and [Make](https://webcapture.io/automations/make) guides show the same async ticket in each platform's step format.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Python: convert from a URL

# Convert a URL to PDF in Python with requests

requests posts the url and parses the receipt; a second authenticated GET brings back the bytes, and Path.write_bytes puts them on disk.

The endpoint holds the connection until the render finishes, so the script is linear: POST, read the receipt, GET the download, write the file. The 60 second client timeout matches the endpoint's own render budget, and json= sets the request's Content-Type for you.

## The recipe

```
import requests
from pathlib import Path

ORIGIN = "https://webcapture.io"

post = requests.post(
    f"{ORIGIN}/v1/convert",
    headers={"Authorization": "Bearer wc_your_key", "Content-Type": "application/json"},
    json={"url": "https://example.com/"},
    timeout=60,
)
try:
    post.raise_for_status()
except requests.HTTPError as error:
    raise RuntimeError(f"convert failed: {post.status_code} {post.text}") from error
receipt = post.json()

# receipt["download"] names /v1/conversions/<id>.pdf at the serving origin
# and only answers to your key: send the same header
pdf = requests.get(
    receipt["download"],
    headers={"Authorization": "Bearer wc_your_key"},
    timeout=60,
)
pdf.raise_for_status()
Path("page.pdf").write_bytes(pdf.content)
```

The receipt carries id, bytes, attempts, remaining, sourceUrl, sourceType, and download, and remaining is the count left on the account's monthly quota after this render. raise_for_status turns a failed render (502 for a page that would not load) or an exhausted quota (429) into an exception instead of writing an error body to disk. The download GET repeats the Authorization header because the endpoint is key-scoped, as every recipe here repeats it.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Python recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Python: wait for the page](https://webcapture.io/guides/recipes/python-wait-for-page), [curl: convert from a URL](https://webcapture.io/guides/recipes/curl-convert-from-url), [Node: convert from a URL](https://webcapture.io/guides/recipes/node-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: if this is a migration, the [Python wkhtmltopdf guide](https://webcapture.io/wkhtmltopdf-alternatives/python) maps pdfkit's options onto the same call.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Python: wait for the page

# Wait for the page in Python with requests

A page that paints late gets captured before it exists. settle holds the render after load, and waitFor names an element the page must actually show before the capture.

Both knobs ride the same JSON body as the url. settle is a plain hold in milliseconds after the page loads; waitFor goes further and blocks until a css selector matches, capped at ten seconds or the settle budget, whichever is smaller. A selector that never matches fails the render rather than shipping a half-painted page, so pick something the page reliably shows: this recipe waits for a paragraph.

## The recipe

```
import requests
from pathlib import Path

ORIGIN = "https://webcapture.io"

post = requests.post(
    f"{ORIGIN}/v1/convert",
    headers={"Authorization": "Bearer wc_your_key", "Content-Type": "application/json"},
    json={"url": "https://example.com/", "settle": 1500, "waitFor": "p"},
    timeout=60,
)
try:
    post.raise_for_status()
except requests.HTTPError as error:
    raise RuntimeError(f"convert failed: {post.status_code} {post.text}") from error
receipt = post.json()

# the download url names /v1/conversions/<id>.pdf at the serving origin
pdf = requests.get(
    receipt["download"],
    headers={"Authorization": "Bearer wc_your_key"},
    timeout=60,
)
pdf.raise_for_status()
Path("page.pdf").write_bytes(pdf.content)
```

settle takes 0 to 10000 milliseconds and replaces the endpoint's own default hold; waitFor takes a selector of 1 to 500 characters validated by Chromium. The two compose: the selector wait runs first, the settle hold runs on top of it, and both sit inside the same render budget the 60 second client timeout already covers. A late chart, a web font, or a client-side table is what these two fields are for.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `settle`: milliseconds to hold after the page loads before the capture, 0 to 10000; absent keeps the endpoint's default (350).
- `waitFor`: a css selector, 1 to 500 characters, the render waits for after load, capped at min(settle, 10 seconds); a selector that never matches fails the render.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Python recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Python: convert from a URL](https://webcapture.io/guides/recipes/python-convert-from-url), [Node: poll an async render](https://webcapture.io/guides/recipes/node-poll-async), [TypeScript: dark mode](https://webcapture.io/guides/recipes/typescript-dark-mode).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) explains why late-painting pages print early, and what each wait mode fixes.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Python: save to a file

# Save the PDF to a file in Python with requests

The receipt states the document's size in bytes. Stream the download in chunks, count what arrived, and refuse to call the file finished until the two agree.

For documents big enough that you would rather not hold them twice in memory, stream the download with stream=True and iter_content, extend a bytearray, and write once at the end. The receipt's bytes field is the truth the write is checked against; a short download raises instead of passing.

## The recipe

```
import requests
from pathlib import Path

ORIGIN = "https://webcapture.io"

post = requests.post(
    f"{ORIGIN}/v1/convert",
    headers={"Authorization": "Bearer wc_your_key", "Content-Type": "application/json"},
    json={"url": "https://example.com/"},
    timeout=60,
)
try:
    post.raise_for_status()
except requests.HTTPError as error:
    raise RuntimeError(f"convert failed: {post.status_code} {post.text}") from error
receipt = post.json()

# the download url names /v1/conversions/<id>.pdf at the serving origin
chunks = bytearray()
with requests.get(
    receipt["download"],
    headers={"Authorization": "Bearer wc_your_key"},
    stream=True,
    timeout=60,
) as res:
    res.raise_for_status()
    for chunk in res.iter_content(chunk_size=65536):
        chunks.extend(chunk)

if len(chunks) != receipt["bytes"]:
    raise RuntimeError(f"short download: {len(chunks)} of {receipt['bytes']} bytes")
Path("whitepaper.pdf").write_bytes(chunks)
```

The size check is the point of the recipe: the endpoint tells you exactly how many bytes the render produced, so a proxy that truncates the transfer or a disk that fills mid-write becomes a loud error instead of a corrupt pdf.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Python recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Python: convert from a URL](https://webcapture.io/guides/recipes/python-convert-from-url), [curl: name the file](https://webcapture.io/guides/recipes/curl-filename), [Node: download with the key](https://webcapture.io/guides/recipes/node-download-with-auth).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [automations hub](https://webcapture.io/automations) carries the same streaming GET inside n8n, Zapier, and Make workflows.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Python: poll an async render

# Poll an async render in Python with requests

A queue worker should not hold a connection for a render. ?wait=false returns a ticket at once, and the poll loop trades status for the finished bytes.

The ticket answers 202 with the id, the status queued, and two urls: poll for the JSON status and download for the artifact. Poll about once a second; the status walks queued, rendering, ready, and a failed render carries the error in the poll body. Both urls answer only to your key.

## The recipe

```
import time
import requests
from pathlib import Path

ORIGIN = "https://webcapture.io"

post = requests.post(
    f"{ORIGIN}/v1/convert?wait=false",
    headers={"Authorization": "Bearer wc_your_key", "Content-Type": "application/json"},
    json={"url": "https://example.com/"},
    timeout=60,
)
try:
    post.raise_for_status()
except requests.HTTPError as error:
    raise RuntimeError(f"convert failed: {post.status_code} {post.text}") from error
receipt = post.json()
# 202 {"id": "cnv_02b8c9d9", "status": "queued",
#      "poll": ".../v1/conversions/cnv_02b8c9d9",
#      "download": ".../v1/conversions/cnv_02b8c9d9.pdf"}
job = receipt

row = job
for attempt in range(60):
    if row["status"] not in ("queued", "rendering"):
        break
    poll = requests.get(job["poll"], headers={"Authorization": "Bearer wc_your_key"}, timeout=60)
    try:
        poll.raise_for_status()
    except requests.HTTPError as error:
        raise RuntimeError(f"poll failed: {poll.status_code} {poll.text}") from error
    row = poll.json()
    if row["status"] not in ("queued", "rendering"):
        break
    if attempt < 59:
        time.sleep(1)
else:
    raise RuntimeError("render polling timed out after 60 attempts")
if row["status"] != "ready":
    raise RuntimeError(row.get("error") or f"render {row['status']}")

pdf = requests.get(job["download"], headers={"Authorization": "Bearer wc_your_key"}, timeout=60)
pdf.raise_for_status()
Path("page.pdf").write_bytes(pdf.content)
```

The loop polls at most 60 times, sleeps one second between attempts, checks each poll response before parsing it, reads a failed poll's response body, and raises render polling timed out after 60 attempts if the job never finishes. It stops as soon as a poll leaves queued or rendering, downloads on ready, and treats any other terminal status as a failure, so a worker can raise and retry without a second parser. A ready poll row adds attempts and bytes to the status, so the caller knows the document's size before it downloads.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL; ?wait=false is a query parameter on the endpoint, not a body field.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Python recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Node: poll an async render](https://webcapture.io/guides/recipes/node-poll-async), [Python: convert from a URL](https://webcapture.io/guides/recipes/python-convert-from-url), [Python: save to a file](https://webcapture.io/guides/recipes/python-save-to-file).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [webcapture vs PDFShift page](https://webcapture.io/vs/pdfshift) shows why polling matters: theirs is webhook-only, ours answers ?wait=false with a pollable ticket.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Node: convert from a URL

# Convert a URL to PDF in Node with fetch

Node 18+ ships fetch, so the whole recipe is two awaited calls and a writeFile: POST the url, read the receipt, GET the download with the same header.

The POST holds until the render finishes and answers with the receipt JSON. The receipt's download url is the GET that carries the bytes, and it needs the Authorization header again, because the download endpoint answers only to the key that made the render.

## The recipe

```
import { writeFile } from "node:fs/promises";

const ORIGIN = "https://webcapture.io";

const res = await fetch(`${ORIGIN}/v1/convert`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer wc_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"url": "https://example.com/"}),
});
if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
const receipt = await res.json();

// receipt.download: ${ORIGIN}/v1/conversions/cnv_9f2c01ab.pdf
// the download is key-scoped: the GET carries the same header
const file = await fetch(receipt.download, {
  headers: { "Authorization": "Bearer wc_your_key" },
});
if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
await writeFile("page.pdf", Buffer.from(await file.arrayBuffer()));
```

receipt.download is read as a property of the parsed receipt, and the GET that follows repeats the Authorization header. The receipt also carries bytes (the document's size), attempts, and remaining (renders left this month), which is everything a queue worker needs for logging. The failed-convert guard uses await res.text() to read the endpoint's JSON error, so the throw names the real cause.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Node recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Node: poll an async render](https://webcapture.io/guides/recipes/node-poll-async), [Node: download with the key](https://webcapture.io/guides/recipes/node-download-with-auth), [Python: convert from a URL](https://webcapture.io/guides/recipes/python-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: on Node and leaving wkhtmltopdf behind? [the Puppeteer comparison](https://webcapture.io/vs/puppeteer-vs-wkhtmltopdf) is the same migration at the engine level. Wiring the call into an automation stack? The [n8n](https://webcapture.io/automations/n8n), [Zapier](https://webcapture.io/automations/zapier), and [Make](https://webcapture.io/automations/make) guides carry the same request.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Node: poll an async render

# Poll an async PDF render in Node with fetch

For serverless functions and request handlers that cannot hold a connection, ?wait=false returns immediately with a ticket, and the poll url carries the finished document.

The ticket answers 202 with the id, the status queued, and two urls: poll, a JSON status endpoint, and download, the artifact. Poll about once a second; the status walks queued, rendering, ready, and on a failed render the poll body carries the error. Both the poll and the download carry your key.

## The recipe

```
import { writeFile } from "node:fs/promises";

const ORIGIN = "https://webcapture.io";

const ticket = await fetch(`${ORIGIN}/v1/convert?wait=false`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer wc_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"url": "https://example.com/"}),
});
if (!ticket.ok) throw new Error(`convert failed: ${ticket.status} ${await ticket.text()}`);
// 202 {"id": "cnv_02b8c9d9", "status": "queued",
//      "poll": ".../v1/conversions/cnv_02b8c9d9",
//      "download": ".../v1/conversions/cnv_02b8c9d9.pdf"}
const job = await ticket.json();

let row = job;
for (let attempt = 0; attempt  setTimeout(resolve, 1000));
  }
}
if (row.status === "queued" || row.status === "rendering") {
  throw new Error("render polling timed out after 60 attempts");
}
if (row.status !== "ready") throw new Error(row.error ?? `render ${row.status}`);

const file = await fetch(job.download, {
  headers: { "Authorization": "Bearer wc_your_key" },
});
if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
await writeFile("page.pdf", Buffer.from(await file.arrayBuffer()));
```

The loop polls at most 60 times, sleeps one second between attempts, checks each poll response before parsing it, and throws render polling timed out after 60 attempts if the job never finishes. It stops on ready and treats any other terminal status as a failure with the endpoint's own error message. A ready poll row adds attempts and bytes to the status, so the caller knows the size before it downloads. Renders usually finish in seconds; the one-second pace is the endpoint's own guidance, and the ticket's download url stays valid for at least seven days after the render.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL; ?wait=false is a query parameter on the endpoint, not a body field.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Node recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Node: convert from a URL](https://webcapture.io/guides/recipes/node-convert-from-url), [Node: download with the key](https://webcapture.io/guides/recipes/node-download-with-auth), [TypeScript: convert from a URL](https://webcapture.io/guides/recipes/typescript-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [n8n](https://webcapture.io/automations/n8n), [Zapier](https://webcapture.io/automations/zapier), and [Make](https://webcapture.io/automations/make) guides wire the same ticket in each platform's own steps.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### Node: download with the key

# Download a rendered PDF in Node with fetch

The download url is not a shareable link. It answers to the key that made the render, and this recipe is the contract: who gets bytes, who gets 401, who gets 404.

Every other recipe on this site ends with an authenticated GET. This one is that GET, spelled out: the receipt's download url fetched with the key, the same url fetched without it, and the facts a file server or a background worker needs before it treats the url as a link.

## The recipe

```
import { writeFile } from "node:fs/promises";

const ORIGIN = "https://webcapture.io";

// 1. render: the POST answers with the receipt, not the bytes
const res = await fetch(`${ORIGIN}/v1/convert`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer wc_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"url": "https://example.com/"}),
});
if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
const receipt = await res.json();

// receipt.download: ${ORIGIN}/v1/conversions/cnv_9f2c01ab.pdf
// 2. download: the same header goes on the GET
const file = await fetch(receipt.download, {
  headers: { "Authorization": "Bearer wc_your_key" },
});
if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
// 200, application/pdf
console.log(file.status, file.headers.get("content-type"));
await writeFile("page.pdf", Buffer.from(await file.arrayBuffer()));

// 3. what a downloader without the key gets: 401
const stranger = await fetch(receipt.download);
console.log(stranger.status);
```

Three outcomes, all by design: your key fetches the bytes (200); no key or a revoked key gets 401; a different valid key gets 404, because the id is looked up per key and another key's renders are invisible. The url is therefore a short-lived pointer, not storage: fetch the bytes and keep them yourself.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render; the download half of the pattern needs no body field, only the same header.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this Node recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [Node: convert from a URL](https://webcapture.io/guides/recipes/node-convert-from-url), [Python: save to a file](https://webcapture.io/guides/recipes/python-save-to-file), [curl: convert from a URL](https://webcapture.io/guides/recipes/curl-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [security page](https://webcapture.io/security) spells out how keys, artifacts, and access controls are handled server-side. Scheduling the download from an automation stack? The [n8n](https://webcapture.io/automations/n8n), [Zapier](https://webcapture.io/automations/zapier), and [Make](https://webcapture.io/automations/make) guides wire the same fetch into each platform's steps.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### TypeScript: convert from a URL

# Convert a URL to PDF in TypeScript with fetch

The endpoint's sync response has a useful common shape, so it deserves a reusable type: one Receipt interface with documented optionals, one POST, one authenticated GET, and the bytes on disk.

The sync receipt has common fields plus documented optionals, and typing them makes this request and its download explicit to your compiler. The same interface serves the dark-mode and markdown recipes, where only the request body changes.

## The recipe

```
import { writeFile } from "node:fs/promises";

const ORIGIN = "https://webcapture.io";

interface Receipt {
  id: string;
  bytes: number;
  attempts: number;
  remaining: number;
  sourceUrl: string;
  sourceType: "url" | "html";
  download: string;
  sandbox?: true;
  phi?: true;
  filename?: string;
  content?: string;
  destination?: { status: string };
}

const res = await fetch(`${ORIGIN}/v1/convert`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer wc_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"url": "https://example.com/"}),
});
if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
const receipt = (await res.json()) as Receipt;

// receipt.download: ${ORIGIN}/v1/conversions/cnv_9f2c01ab.pdf
const file = await fetch(receipt.download, {
  headers: { "Authorization": "Bearer wc_your_key" },
});
if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
await writeFile("page.pdf", Buffer.from(await file.arrayBuffer()));
console.log(`${receipt.bytes} bytes, ${receipt.remaining} renders left this month`);
```

remaining comes straight off the typed receipt and reads as the key's quota position after the render, which makes it the natural field to log alongside bytes. The type is a minimal common receipt for this sync response plus the documented optionals: sandbox, phi, filename, content, and destination. It is not the exact whole API response; the async ticket from ?wait=false is a different shape, and the polling recipe handles that one.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `url`: the page address to render, a public http(s) URL.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this TypeScript recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [TypeScript: dark mode](https://webcapture.io/guides/recipes/typescript-dark-mode), [TypeScript: markdown](https://webcapture.io/guides/recipes/typescript-markdown), [Node: convert from a URL](https://webcapture.io/guides/recipes/node-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: the [product page](https://webcapture.io/html-to-pdf-api) keeps the engine, the pricing, and the same quick start in curl on one sheet.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### TypeScript: dark mode

# Render dark-mode PDFs in TypeScript with fetch

One field flips the render's color scheme: colorScheme: dark emulates a visitor with prefers-color-scheme: dark before the page paints.

colorScheme takes light, dark, or site, and light is the default. dark emulates the media query before navigation, so the page's own dark stylesheet wins on the first paint instead of flashing light; site leaves the decision to the page. It moves only pages that answer prefers-color-scheme, so a page with no dark stylesheet renders identically in both.

## The recipe

```
import { writeFile } from "node:fs/promises";

const ORIGIN = "https://webcapture.io";

interface Receipt {
  id: string;
  bytes: number;
  attempts: number;
  remaining: number;
  sourceUrl: string;
  sourceType: "url" | "html";
  download: string;
  sandbox?: true;
  phi?: true;
  filename?: string;
  content?: string;
  destination?: { status: string };
}

const res = await fetch(`${ORIGIN}/v1/convert`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer wc_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"url": "https://example.com/", "colorScheme": "dark"}),
});
if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
const receipt = (await res.json()) as Receipt;

// receipt.download: ${ORIGIN}/v1/conversions/cnv_9f2c01ab.pdf
const file = await fetch(receipt.download, {
  headers: { "Authorization": "Bearer wc_your_key" },
});
if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
await writeFile("page-dark.pdf", Buffer.from(await file.arrayBuffer()));
```

The choice is per call, so the same url can render both schemes in two calls and ship whichever the user's context needs. Dark mode is where pdf renders most often betray a site: a browser screenshot inherits the visitor's scheme, while an API render needs the field spelled out, which is exactly what this body does.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `colorScheme`: light or dark renders the page as if the visitor preferred that scheme; site keeps the page's own choice; light is the default.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this TypeScript recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [TypeScript: convert from a URL](https://webcapture.io/guides/recipes/typescript-convert-from-url), [Python: wait for the page](https://webcapture.io/guides/recipes/python-wait-for-page), [TypeScript: markdown](https://webcapture.io/guides/recipes/typescript-markdown).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: [the formatting guide](https://webcapture.io/guides/html-to-pdf-without-losing-formatting) covers colorScheme next to the other per-request tuning the print accepts.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).

### TypeScript: markdown

# Extract markdown in TypeScript with fetch

The same endpoint extracts: format: markdown skips the print step and hands back the page's text as markdown, through the same receipt and the same download.

format is pdf by default and markdown is one of its alternatives. The response is still the receipt with a download url; fetching it serves the extracted markdown as text/markdown, and the download's Content-Disposition names the file with an .md extension even though the url itself keeps its .pdf suffix.

## The recipe

```
import { writeFile } from "node:fs/promises";

const ORIGIN = "https://webcapture.io";

interface Receipt {
  id: string;
  bytes: number;
  attempts: number;
  remaining: number;
  sourceUrl: string;
  sourceType: "url" | "html";
  download: string;
  sandbox?: true;
  phi?: true;
  filename?: string;
  content?: string;
  destination?: { status: string };
}

const res = await fetch(`${ORIGIN}/v1/convert`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer wc_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"url": "https://example.com/", "format": "markdown"}),
});
if (!res.ok) throw new Error(`convert failed: ${res.status} ${await res.text()}`);
const receipt = (await res.json()) as Receipt;

// receipt.download: ${ORIGIN}/v1/conversions/cnv_ec487efd8c301fc8.pdf
// the url keeps its .pdf suffix, but the bytes are markdown:
const file = await fetch(receipt.download, {
  headers: { "Authorization": "Bearer wc_your_key" },
});
if (!file.ok) throw new Error(`download failed: ${file.status} ${await file.text()}`);
// text/markdown; charset=utf-8
console.log(file.headers.get("content-type"));
await writeFile("page.md", await file.text(), "utf8");
```

Markdown extraction runs in the same quota and behind the same key as pdf: one extraction is one document against the month's count. Extraction reads the rendered page, so JavaScript already ran and the receipt's bytes field counts the markdown, which is usually a few kilobytes.

Same-key GET: 401 without it; 404 for another key.

## The fields this recipe uses

- `format`: pdf is the default; markdown extracts the rendered page's text as markdown instead of printing it.

## Related

- Next: [Get a free key](https://webcapture.io/get-started) to run this TypeScript recipe, or [read the API docs](https://webcapture.io/docs).
- More recipes: [TypeScript: convert from a URL](https://webcapture.io/guides/recipes/typescript-convert-from-url), [TypeScript: dark mode](https://webcapture.io/guides/recipes/typescript-dark-mode), [Node: convert from a URL](https://webcapture.io/guides/recipes/node-convert-from-url).
- [The webcapture API reference](https://webcapture.io/docs): every field the convert endpoint admits, with the response shapes and the error table.
- [The recipes hub](https://webcapture.io/guides/recipes): the same endpoint in the other languages.
- Going deeper: markdown output is a differentiator: the [comparison grid](https://webcapture.io/vs/pdfshift) marks it ours-yes, theirs-no.
- [The free URL to PDF tool](https://webcapture.io/tools/url-to-pdf): try the render in a browser before you wire the call.

## Method

Source: [convert field reference](https://webcapture.io/docs#convert-fields), checked 2026-10-05; the sample was rewritten 2026-10-26 so response errors surface and polling is bounded. [Corrections](mailto:hello@webcapture.io).