PDFCraft
HTML to PDF API

HTML in,
PDF out.

One POST. Sub-second warm renders from a real Chromium, with the print options you actually need — margins, headers, footers, page ranges, wait conditions.

100 renders a month, free, no card. Then $9 for 2,000.

Try it right now — this key is live
curl -X POST https://api.pdfcraft.dev/v1/render \
  -H "Authorization: Bearer sk_live_dS0nS7pl4Xe2ViUb-4TeKqRQA1khKqOq" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>hello</h1>"}' \
  --output hello.pdf

Public on purpose, capped at 5 renders an hour per IP so you can try before signing up.

Why it is built this way

Warm Chromium, always

One browser per process, held open, handing out a fresh isolated context per render. A warm render is roughly ten times faster than a cold launch — which is what every serverless PDF service pays on every single request.

You are billed when the browser ran

Successes bill. A document that threw bills, because Chromium did the work. Timeouts, bad keys, malformed bodies and our own failures are free. The error reference marks every code, so the published rule and the charged rule are the same line of data.

Your documents are not stored

A synchronous render never touches disk — it exists in memory for the length of the render and then the context is destroyed. Output PDFs live behind a signed link for 24 hours, then a sweep job deletes them. Render records keep metadata only.

Quickstart
01

Run the curl

You now have a PDF on disk. That is the whole product — everything else is options.

02

Get your own key

Enter your email, click the link we send, copy the key off your dashboard. No password to choose.

Create an account
03

Install the SDK

Zero dependencies, TypeScript types included. Or keep using fetch — it is one endpoint.

Install
npm install @pdfcraft-dev/pdf
Use
import { Renderer } from "@pdfcraft-dev/pdf";

const r = new Renderer(process.env.PDFCRAFT_API_KEY!);
const pdf = await r.render({ html: "<h1>hi</h1>" });          // Buffer
const { url } = await r.renderToUrl({ url: "https://example.com" });
The four endpoints
POST/v1/renderRender now. Returns application/pdf, or JSON with a signed URL when you send output: "url".
POST/v1/render/asyncQueue it and get a signed callback at your callback_url. Same body plus that one field.
GET/v1/renders/:idPoll one render.
GET/v1/usageRenders used, plan limit, when the period resets.
And the other direction: PDF to JSON

POST a PDF and get its tables and labelled fields back as JSON, each with a bounding box saying where on the page it came from. A table broken across fourteen pages comes back as one table.

No OCR and no model — extraction is geometric, so the same document always gives the same answer, and a scan is an honest 422 rather than a confident guess. Read the extraction docs.

curl -X POST https://api.pdfcraft.dev/v1/extract \
  -H "Authorization: Bearer $PDFCRAFT_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"file\": \"$(base64 -w0 invoice.pdf)\"}"

# { "fields": { "Invoice Number": {
#     "value": "INV-2026-0417", "page": 1,
#     "bbox": [115, 55, 176, 64] } },
#   "tables": [ … ], "usage": { "pages": 1 } }
Rendering a page behind a login

Send a url instead of html, and pass the headers or cookies your own app would send. Wait for the selector that means the page is actually ready, rather than guessing with a delay.

curl -X POST https://api.pdfcraft.dev/v1/render \
  -H "Authorization: Bearer $PDFCRAFT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/invoices/1042",
    "headers": { "Authorization": "Bearer YOUR_APP_TOKEN" },
    "cookies": [{ "name": "session", "value": "…" }],
    "options": { "waitFor": { "selector": "#ready", "networkIdle": true } },
    "output": "url"
  }'
Verifying an async callback

Your webhook secret is on the dashboard and is rotatable. It signs render callbacks only — it is not your billing webhook secret.

import { createHmac, timingSafeEqual } from "node:crypto";

const expected = createHmac("sha256", process.env.PDFCRAFT_WEBHOOK_SECRET!)
  .update(rawBody)
  .digest("hex");

const ok = timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.headers["x-signature"] as string),
);
Shape what ships next

Requested by developers

Every request below came from someone building on the API. Vote to move something up; I build from the top of this list.

Loading the board…

No account needed. Three submissions an hour.

Start with 100 renders a month.

No card, no trial clock, no sales call. When you outgrow it, $9 buys 2,000.