API reference
Four endpoints, one request shape, one error shape. If you have read this page you have read all of it.
Going the other way — a PDF in, structured JSON out — is the extraction API. Same key, same error shape, same idempotency.
Start here
Everything is JSON over HTTPS at https://api.pdfcraft.dev. There is no SDK requirement, no session to establish and no handshake — one authenticated POST returns a PDF.
curl -X POST https://api.pdfcraft.dev/v1/render \
-H "Authorization: Bearer $PDFCRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>hello</h1>"}' \
--output hello.pdfAuthentication
A bearer token on every request. Keys are created on your dashboard and shown exactly once — we store a one-way hash, so a lost key is reissued rather than recovered.
Authorization: Bearer sk_live_…
A key is a bearer credential: whoever holds it spends your quota. Keep it server-side. If it has to reach a browser, proxy the call through your own backend rather than shipping the key to the client. A missing, malformed or revoked key returns invalid_api_key and is never billed.
POST /v1/render
/v1/renderRenders and returns in the same request. Typical warm render is well under a second; the hard ceiling is options.timeoutMs, maximum 120 seconds.
Request body
Generated from the same constant the API builds its validator from, so this table cannot drift from what the server accepts.
| Field | Type | Default | Notes |
|---|---|---|---|
html | string, ≤5000000 chars | — | The document to render. Mutually exclusive with url; exactly one is required. |
url | string, ≤2000 chars | — | A public http(s) URL to render. Mutually exclusive with html. |
output | binary | url | binary | "binary" streams application/pdf back; "url" uploads and returns a signed link. |
filename | string, ≤255 chars | document.pdf | Used for Content-Disposition on binary output and in the signed URL. |
headers | object | — | url input only. Extra request headers, e.g. an Authorization header. |
cookies | object | — | url input only. Array of {name, value, domain?, path?} seeded into the context. |
options | object | — | Page format, margins, scale, headers and footers, wait conditions. Full option reference → |
Exactly one of html or url. Sending both, or neither, is invalid_request. headers and cookies apply to url input only — they are how you render a page that sits behind your own login.
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"
}'Binary or URL
output decides what comes back, and it is the one choice worth thinking about up front.
| Value | Response | Use when |
|---|---|---|
binary | application/pdf streamed straight back | You are saving or forwarding the bytes yourself. Nothing is written to our disk at all. |
url | JSON with a signed link and metadata | You want to hand a download link to a browser or an email, or the file is large. |
{
"id": "rnd_01JQ…",
"url": "https://…/rnd_01JQ….pdf?sig=…",
"expires_at": "2026-09-12T09:14:00Z",
"pages": 3,
"bytes": 84213,
"duration_ms": 812
}That link expires — 24 hours by default — and a sweep job then deletes the object. There is no archive and no way to extend it, so download what you need.
POST /v1/render/async
/v1/render/asyncThe same body plus a callback_url. Returns 202 immediately with an id, queues the work, and POSTs a signed payload to your URL when it finishes. Reach for this when a render is slow enough that holding an HTTP connection open is awkward — a big report, or a page with a long waitFor.
curl -X POST https://api.pdfcraft.dev/v1/render/async \
-H "Authorization: Bearer $PDFCRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>big report</h1>",
"callback_url": "https://yourapp.com/hooks/pdfcraft"
}'Async jobs are queued with your document in them so they survive a restart. A successful job is discarded within the hour; a failed one is kept longer so it can be retried. If a particular document should never sit in a queue, use the synchronous endpoint.
Verifying a callback
Every callback carries an X-Signature header: HMAC-SHA256 of the raw request body, hex-encoded, using your webhook secret. That secret is on your dashboard and is rotatable. Verify before you trust the payload — the URL is public, so anyone can POST to it.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received. A JSON body-parser that
// re-serialises will produce a different string and never match.
const expected = createHmac("sha256", process.env.PDFCRAFT_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const given = req.headers["x-signature"] as string;
const ok =
expected.length === given.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(given));
if (!ok) return res.status(401).end();GET /v1/renders/:id
/v1/renders/:idThe state of one render — status, page count, byte size, duration, and the error code if it failed. Useful as a fallback when a callback did not arrive, and for reconciling your own records. An id that is not yours returns not_found, not a 403, so the endpoint cannot be used to discover whether an id exists.
GET /v1/usage
/v1/usage{
"used": 412,
"limit": 2000,
"overage_count": 0,
"period_start": "2026-09-01T00:00:00Z",
"resets_at": "2026-10-01T00:00:00Z"
}Cheap and unbilled. Poll it on a schedule rather than parsing the X-Renders-Remaining header off every response if you want to alert before you hit a limit.
Idempotency
Send an Idempotency-Key header on anything you would hate to pay for twice. A repeat of the same key returns the original render instead of producing a new one.
Idempotency-Key: invoice-1042-v1
Keys are scoped to your account. Reusing one with a different body is rejected rather than silently returning the old result — that mismatch is almost always a bug in the caller, and quietly serving the wrong PDF would be worse than an error.
Rate limits and quota
Two separate ceilings, with two different failure modes.
| Limit | Scope | When you hit it |
|---|---|---|
| Requests per second | Per plan | rate_limited with a Retry-After header. Not billed. Back off and retry. |
| Renders per month | Per account | Free plans stop with quota_exceeded. Paid plans keep rendering and accrue overage. |
| Plan | Renders / month | Requests / second | Beyond the quota |
|---|---|---|---|
free | 100 | 2 | Stops — HTTP 429 |
starter | 2,000 | 20 | $0.004 per render |
growth | 10,000 | 20 | $0.004 per render |
scale | 50,000 | 20 | $0.004 per render |
business | 250,000 | 150 | $0.004 per render |
A render bills when Chromium actually ran. That means successes — and it also means a render that failed because your document threw or never became ready. Anything rejected before the browser started is free: a bad key, a malformed body, a quota refusal, a rate limit. So are timeouts and our own internal errors. The error reference marks every code, because the published rule and the charged rule are the same line of data.
TypeScript SDK
Optional — it is one endpoint and fetch is fine. The SDK gives you types, retries and a narrower error surface, with zero dependencies of its own.
npm install @pdfcraft-dev/pdf
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" });Where to go next
- Option reference — every field under
options, with types and defaults. - Error reference — every code, the HTTP status, and whether it bills. The
docs_urlin any error response links straight to its entry. - Playground — edit HTML and render it in the browser, with the demo key or your own.