POSThttps://api.pdfpipe.xyz/v1/pdf
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: 100 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); up to 10 answer in the call, larger batches run in the background (202 + batch_id); each stored item counts as one render |
| GET/v1/pdf/batch/:id | Bearer key | Status and per-item results of a background batch |
| POST/v1/pdf/merge | Bearer key | Merge 2 to 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/schedules | Bearer key | Create a scheduled batch (Starter+ plan): cron or daily / weekly / monthly, in your time zone |
| GET/v1/schedules | Bearer key | List this key's schedules with next run and last result |
| GET/v1/schedules/:id | Bearer key | Read one schedule |
| PATCH/v1/schedules/:id | Bearer key | Replace the data list, rename, pause or resume |
| DELETE/v1/schedules/:id | Bearer key | Delete a schedule |
| POST/v1/support | Bearer key | Open a support request, prioritised by plan |
| GET/v1/status | none | Live status, 90-day uptime and incidents, checked every 5 minutes (real render probe hourly) |
| 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 | domcontentloaded, then waits for stylesheets, fonts and images |
| 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 and Business. 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 (a top-level store: true is accepted but changes nothing; store: false is refused) 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, and an item that fails is not charged. The endpoint is available on Starter and above.
- Up to 10 items: rendered during the call, a few at a time, and answered with
200and a result for every item. - More than 10 items, or any batch sent with
"async": true: answered at once with202, abatch_idand astatus_url. Every item is then rendered in the background. PollGET /v1/pdf/batch/:idor pass awebhook_urlto be told when it is complete. Allow roughly 2 to 4 seconds per document, two at a time, so 250 documents take about 4 to 9 minutes. - No item is left out: each one ends with a stored document or an error with a
code. A renderer hiccup is retried in the background before an item is reported as failed.
| 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();
// More than 10 items (or async: true): 202, then poll.
// { batch_id: "bat_...", status: "queued", total: 120, status_url: "https://api.pdfpipe.xyz/v1/pdf/batch/bat_..." }
const batch = await (await fetch(status_url, { headers: { Authorization: "Bearer pp_live_your_key" } })).json();
// { status: "queued" | "running" | "complete", total, succeeded, failed, pending, results: [...] }Response
A batch answered in the call returns 200, even when individual items fail. A background batch returns 202 and its results come from the status URL or the webhook, in the same shape. 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" ("pending" while a background batch is still running) |
| 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) |
| results[].code | Why it failed: e.g. invalid_request, renderer_unavailable, browser_daily_limit (the renderer's daily capacity is used up: resend after 00:00 UTC), batch_time_limit (a call with up to 10 items ran out of time before this item started: resend it or use async) |
| 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 a batch.complete event with every result to your endpoint: before responding for a batch answered in the call, and when the last item finishes for a background batch (the webhook-id is then the batch_id). Include webhook_secret and every delivery is signed using Standard Webhooks HMAC-SHA256 in the webhook-signature header (format: v1,base64…).
Secret format: 16 to 256 characters. Preferred is the Standard Webhooks form, whsec_ followed by base64, where the HMAC key is the decoded base64. A secret without the prefix is used as base64 when it is valid base64, otherwise its UTF-8 bytes are the key. A whsec_ secret whose remainder is not base64 is refused with 400 before anything renders.
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_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", // whsec_ + base64
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.slice("whsec_".length)));
// 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 →
Scheduled batches
Store a batch once and it runs on its own: month-end statements, weekly audit reports, daily summaries. Send a template (or HTML) and a data list with one object per document to POST https://api.pdfpipe.xyz/v1/schedules. Each run goes through the same pipeline as /v1/pdf/batch, so every document counts as one render, is stored for your plan's retention period, and appears in /v1/documents.
- Timing:
every(daily,weeklywithday_of_week0-6,monthlywithday_of_month1-28) plusat("HH:MM"), or a five-fieldcronexpression. Both are read intimezone(IANA, default UTC). At most one run an hour. - A run starts within 5 minutes after its scheduled time and renders in the background like a large batch, so every item in the list is rendered or reported with an error code. Until it finishes,
last_statusreadsrunningandlast_result.batch_idcan be polled atGET /v1/pdf/batch/:id. Missed runs are not replayed. datais capped at your plan's batch size (10 on Starter up to 250 on Business).filenamecan use the same{{field}}placeholders as the template. Every item also gets{{run.date}},{{run.month}}and{{run.previous_month}}, on the schedule's clock. Replace the list any time withPATCH /v1/schedules/:id.- Data that changes between runs: instead of
template_idorhtml, give aurlpattern such ashttps://app.example.com/reports/{{id}}?month={{run.previous_month}}. Each run renders your live page, so the numbers are current. - Delivery: documents are always stored. Add
webhook_url(andwebhook_secretto sign it) to get aschedule.run.completeevent with every document link after each run. - Up to 25 schedules per account. A schedule pauses itself after 5 failed runs in a row.
const res = await fetch("https://api.pdfpipe.xyz/v1/schedules", {
method: "POST",
headers: { Authorization: "Bearer pp_live_your_key", "Content-Type": "application/json" },
body: JSON.stringify({
name: "Monthly statements",
every: "monthly", day_of_month: 1, at: "06:00", timezone: "Europe/London",
template_id: "tmpl_statement", // or html: "<h1>{{name}}</h1>..."
filename: "statement-{{account_id}}.pdf",
data: [
{ account_id: "A-1001", name: "Acme Ltd", balance: "1,240.00" },
{ account_id: "A-1002", name: "Globex", balance: "310.50" },
],
webhook_url: "https://yourserver.com/hooks/statements",
webhook_secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
}),
});
const schedule = await res.json();
// { id: "sch_...", cron: "0 6 1 * *", next_run_at: "2026-10-01T05:00:00.000Z", ... }
// Later: GET /v1/schedules shows last_run_at, last_status and last_result.
// DELETE /v1/schedules/sch_... stops it.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.