PDFCraft

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.

The whole product, in one call
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.pdf

Authentication

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

POST/v1/render

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

FieldTypeDefaultNotes
htmlstring, ≤5000000 charsThe document to render. Mutually exclusive with url; exactly one is required.
urlstring, ≤2000 charsA public http(s) URL to render. Mutually exclusive with html.
outputbinary | urlbinary"binary" streams application/pdf back; "url" uploads and returns a signed link.
filenamestring, ≤255 charsdocument.pdfUsed for Content-Disposition on binary output and in the signed URL.
headersobjecturl input only. Extra request headers, e.g. an Authorization header.
cookiesobjecturl input only. Array of {name, value, domain?, path?} seeded into the context.
optionsobjectPage 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.

Rendering an authenticated page
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.

ValueResponseUse when
binaryapplication/pdf streamed straight backYou are saving or forwarding the bytes yourself. Nothing is written to our disk at all.
urlJSON with a signed link and metadataYou want to hand a download link to a browser or an email, or the file is large.
Response when output is "url"
{
  "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

POST/v1/render/async

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

Node
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

GET/v1/renders/:id

The 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

GET/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.

LimitScopeWhen you hit it
Requests per secondPer planrate_limited with a Retry-After header. Not billed. Back off and retry.
Renders per monthPer accountFree plans stop with quota_exceeded. Paid plans keep rendering and accrue overage.
PlanRenders / monthRequests / secondBeyond the quota
free1002Stops — HTTP 429
starter2,00020$0.004 per render
growth10,00020$0.004 per render
scale50,00020$0.004 per render
business250,000150$0.004 per render
What you are actually charged for

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_url in any error response links straight to its entry.
  • Playground — edit HTML and render it in the browser, with the demo key or your own.