API reference
PDFPipe is an HTML-to-PDF API. Send a POST request to https://api.pdfpipe.xyz/v1/pdf with a JSON body containing an html string or a url, and receive an application/pdf response. No rendering engine to install, no server to operate. Free tier: 500 documents per month.
1. Get an API key
Keys look like pp_live_…. Pick a plan (the Hobby tier is free, no card required) and you get one immediately after signup. No key needed to try it on the live playground.
2. Render a PDF
POST to /v1/pdf with html (or url) and optional options. The response body is the raw application/pdf.
curl -X POST https://api.pdfpipe.xyz/v1/pdf \
-H "Authorization: Bearer pp_live_your_key" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Invoice #4012</h1>","options":{"format":"A4"}}' \
--output invoice.pdfconst res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: "Bearer pp_live_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
html: "<h1>Invoice #4012</h1>",
options: { format: "A4" },
}),
});
const pdf = Buffer.from(await res.arrayBuffer());
require("fs").writeFileSync("invoice.pdf", pdf);import requests
res = requests.post(
"https://api.pdfpipe.xyz/v1/pdf",
headers={"Authorization": "Bearer pp_live_your_key"},
json={"html": "<h1>Invoice #4012</h1>", "options": {"format": "A4"}},
)
with open("invoice.pdf", "wb") as f:
f.write(res.content)Render from a URL
curl -X POST https://api.pdfpipe.xyz/v1/pdf \
-H "Authorization: Bearer pp_live_your_key" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","options":{"format":"A4"}}' \
--output page.pdfEndpoints
| Method & path | Auth | Purpose |
|---|---|---|
| POST /v1/pdf | Bearer key | Render HTML or a URL to PDF (metered) |
| POST /v1/pdf/batch | Bearer key | Batch render up to 10-500 items (plan-based); each item counts as one render; webhook notification on completion |
| POST /v1/pdf/merge | Bearer key | Merge 2–20 PDFs (by document ID, URL, or base64) into one; Starter+ plan |
| GET /v1/me | Bearer key | Inspect the key: plan, usage this period, limit |
| GET /v1/documents | Bearer key | List stored documents for this key (paginated) |
| GET /v1/documents/:id | Bearer key | Download a stored document by ID |
| GET /v1/documents/:id/metadata | Bearer key | Get document metadata as JSON (no download) |
| DELETE /v1/documents/:id | Bearer key | Delete a stored document immediately |
| POST /v1/demo | none | Playground render, rate-limited per IP per day |
| GET /health | none | Liveness check |
PDF options
| Option | Values | Default |
|---|---|---|
| format | A4 · A3 · A5 · Letter · Legal · Tabloid | A4 |
| landscape | true / false | false |
| margin | CSS length or {top, bottom, left, right} | 1cm |
| print_background | true / false | true |
| scale | 0.1 to 2.0 | 1.0 |
| page_ranges | "1-3, 5" | all pages |
| prefer_css_page_size | true / false (use @page CSS) | false |
| media | print / screen | |
| timeout_ms | 1000 to 60000 | 30000 |
| wait_until | load · domcontentloaded · networkidle0 · networkidle2 | networkidle0 |
| wait_for | CSS selector to wait for before capture | none |
| wait_ms | extra delay after load, up to 5000 | 0 |
| inject_css | CSS string injected before capture | none |
| header_html | HTML string rendered as running page header (.pageNumber, .totalPages, .date classes substituted) | none |
| footer_html | HTML string rendered as running page footer (same class substitution) | none |
| tabular_nums | true / false: forces consistent digit widths via font-variant-numeric | false |
| deduplicate_images | true / false: merges identical image XObjects to reduce file size | false |
| pdf_a | true / false: adds PDF/A-1b XMP metadata marker (best-effort; full conformance requires tagged structure) | false |
Reliable by default
The render waits for web fonts and images before drawing, so text never freezes in a fallback face and pictures are never half-loaded. If a font or image does fail, the PDF is still returned and the details appear in the X-PDFPipe-Warnings response header so you can act on them without losing the document. Page-break rules like break-inside: avoid are honored under print emulation, which keeps long tables from splitting mid-row. Use inject_css to add print-specific overrides, custom @font-face rules, or forced page-break points without touching your application HTML. Because the render uses a current browser engine, modern CSS works exactly as it does in Chrome: flexbox, grid, custom properties, web fonts, and frameworks like Tailwind and Bootstrap. Inline the stylesheet or link it from a public HTTPS CDN. One caveat: responsive breakpoints evaluate at the print page width, so design for the page size rather than a screen. When something does go wrong, the error body includes a machine-readable error field (timeout, resource_load_failed, quota_exceeded, etc.) alongside the human-readable detail. A slow page returns a timeout you can raise with timeout_ms (up to 60 s), and a renderer at capacity returns a 503 you can safely retry.
Adding images
There is no upload step. You add an image by referencing it from your HTML, in one of two ways.
1. Embed it as a data URI. Base64-encode the image and inline it. Nothing is fetched, so it can never fail to load and is never blocked. Best for logos and small fixed assets. Keep it modest in size: base64 adds about a third to the byte count.
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." width="160" />2. Reference a hosted HTTPS URL. The renderer fetches it and waits for it to finish loading before drawing, so it is never half-loaded. The host must be public (private, internal, and localhost addresses are blocked). For a per-document image, put the URL in a template placeholder.
<img src="https://yourcdn.com/logo.png" width="160" />
<!-- per-document image, filled from your template data -->
<img src="{{logo_url}}" width="160" />What will not load: assets behind authentication, cookies, or a private CDN (no credentials are sent), file:// and blob: sources, and relative paths like /logo.png when you send raw HTML. Raw HTML has no base URL, so use an absolute URL or a data URI; relative paths only resolve when you render a url. If an image still fails, the PDF is returned anyway and the problem is listed in the X-PDFPipe-Warnings response header.
Usage & limits
Every successful render returns your usage in response headers: X-PDFPipe-Usage, X-PDFPipe-Limit, and X-PDFPipe-Plan. When the monthly limit is reached the API returns 402. Upgrade your plan and the limit resets immediately.
Document archive
Stored documents are visible in your dashboard when signed in, and retrievable via the /v1/documents endpoints using your Bearer key. Unauthenticated visitors cannot see stored documents.
Add "store": true to any render request and the API returns JSON instead of raw bytes. An optional filename string sets the download name. The JSON body includes:
| Field | Value |
|---|---|
| document_id | Unique document ID |
| document_url | Direct download URL (authenticated) |
| document_expires | ISO 8601 expiry timestamp |
| size_bytes | PDF size in bytes |
| used / limit | Current quota usage |
The same values are also present in response headers (X-PDFPipe-Document-Id, X-PDFPipe-Document-Url, X-PDFPipe-Document-Expires) for compatibility with HTTP clients that prefer headers over JSON bodies.
Retention varies by plan: 1 day on the free tier, 30 days on Starter, 1 year on Growth, and 2 years on Scale. Compare plans. Use GET /v1/documents to list active documents, GET /v1/documents/:id/metadata to fetch JSON metadata (filename, size, expiry) without downloading the bytes, GET /v1/documents/:id to download one, and DELETE /v1/documents/:id to remove one before it expires. The list endpoint is paginated: pass limit (1-100, default 100) and before (ISO timestamp) to page through large archives. When the response includes has_more: true, pass the next_before value as the next before parameter.
// 1. Render and store — store:true returns JSON, not raw bytes
const render = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: { Authorization: "Bearer pp_live_your_key", "Content-Type": "application/json" },
body: JSON.stringify({ html: "<h1>Invoice #4012</h1>", store: true, filename: "invoice-4012.pdf" }),
});
const { document_id: docId, document_url } = await render.json();
// 2. List all stored documents
const list = await fetch("https://api.pdfpipe.xyz/v1/documents", {
headers: { Authorization: "Bearer pp_live_your_key" },
});
const { documents } = await list.json();
// 3. Get metadata without downloading (filename, size, expiry)
const meta = await fetch(`https://api.pdfpipe.xyz/v1/documents/${docId}/metadata`, {
headers: { Authorization: "Bearer pp_live_your_key" },
});
// { id, filename, size_bytes, created_at, expires_at, url, expired }
const info = await meta.json();
// 4. Download a document by ID
const file = await fetch(`https://api.pdfpipe.xyz/v1/documents/${docId}`, {
headers: { Authorization: "Bearer pp_live_your_key" },
});
const pdf = Buffer.from(await file.arrayBuffer());
// 5. Delete a document
await fetch(`https://api.pdfpipe.xyz/v1/documents/${docId}`, {
method: "DELETE",
headers: { Authorization: "Bearer pp_live_your_key" },
}); // 204 No ContentDocument archive is available on all plans. A free account is required to retrieve stored documents. Sign up free →
Batch rendering
Render multiple HTML or URL inputs in one request with POST https://api.pdfpipe.xyz/v1/pdf/batch. Every item is stored automatically and you receive a document ID and download URL for each one. Top-level options apply to all items; per-item options override them. Each item counts as one render against your monthly quota. The endpoint is available on Starter and above.
| Plan | Max items per call |
|---|---|
| Hobby | Not available |
| Starter | 10 |
| Growth | 50 |
| Scale | 100 |
| Business | 250 |
| Enterprise | 500 |
Request
Pass a requests array where each item has html or url, an optional filename, and optional per-item options.
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf/batch", {
method: "POST",
headers: { Authorization: "Bearer pp_live_your_key", "Content-Type": "application/json" },
body: JSON.stringify({
options: { format: "A4" }, // shared across all items
requests: [
{ html: "<h1>Invoice #4012</h1>", filename: "invoice-4012.pdf" },
{ html: "<h1>Invoice #4013</h1>", filename: "invoice-4013.pdf" },
{ url: "https://example.com/report", filename: "report.pdf" },
],
}),
});
const { results, usage } = await res.json();Response
The response is always 200, even when individual items fail. Inspect each result's status field to handle partial failures.
| Field | Value |
|---|---|
| results[].index | Zero-based position in the request array |
| results[].status | "ok" or "error" |
| results[].id | Document ID (present on success) |
| results[].url | Authenticated download URL (present on success) |
| results[].filename | Stored filename (present on success) |
| results[].size_bytes | PDF size in bytes (present on success) |
| results[].expires | ISO 8601 expiry timestamp (present on success) |
| results[].error | Error message string (present on failure) |
| usage.rendered | Number of items that succeeded |
| usage.total_this_month | Total renders used this billing period after this call |
| usage.limit | Monthly render limit for your plan |
Webhooks
Add webhook_url to any batch request and the API will POST the completed results to your endpoint before responding. Include webhook_secret and every delivery is signed using Standard Webhooks HMAC-SHA256 in the webhook-signature header (format: v1,base64…). The API call still returns the same results: the webhook is a side-channel notification, not a replacement.
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf/batch", {
method: "POST",
headers: { Authorization: "Bearer pp_live_your_key", "Content-Type": "application/json" },
body: JSON.stringify({
webhook_url: "https://yourserver.com/hooks/pdfpipe",
webhook_secret: "whsec_your_signing_secret",
requests: [
{ html: "<h1>Report</h1>", filename: "report.pdf" },
],
}),
});
const { results, webhook_delivered } = await res.json();
// webhook_delivered: true if your endpoint returned 2xx within 8 s
// --- On your server, verify the signature (Standard Webhooks):
// const msgId = req.headers["webhook-id"];
// const msgTs = req.headers["webhook-timestamp"];
// const toSign = `${msgId}.${msgTs}.${rawBody}`;
// const expected = "v1," + hmacSha256Base64(toSign, base64Decode(secret.replace("whsec_", "")));
// if (expected !== req.headers["webhook-signature"]) throw new Error("bad sig");The batch endpoint is not available in the no-key playground. Use your API key and call POST https://api.pdfpipe.xyz/v1/pdf/batch directly. Get a key →
AI agents (MCP)
PDFPipe ships an MCP server so an AI agent can generate a document in one tool call. Point any MCP client at pdfpipe-mcp-server with your PDFPIPE_API_KEY and the pdfpipe_generate_pdf tool becomes available.