PDFs
Generate a PDF from a template or from HTML with POST /v1/pdf, render it in the background, read its warnings and fetch it again with GET /v1/pdf/:id.
On this page
POST /v1/pdf is the endpoint you will use most: it turns a stored template plus your data, or a piece of HTML, into a PDF. By default the request waits for the PDF and returns it; with "async": true it returns at once and tells you by webhook when the PDF is ready. GET /v1/pdf/:id returns a document's record and a fresh download link later. New to the API? Start with the Quickstart, and see the overview for what every request has in common.
POST /v1/pdf
Generates a PDF and returns it, either as the response body (binary, the default) or as a signed link (url). Rendering is limited to 30 seconds and 50 pages.
curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-INV-042" \
-d '{
"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
"data": {"number": "INV-042", "customer": {"name": "Acme Ltd"}, "total": 1234.5},
"filename": "invoice-042",
"response": "url"
}'const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
'Idempotency-Key': 'invoice-INV-042',
},
body: JSON.stringify({
template_id: '8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b',
data: { number: 'INV-042', customer: { name: 'Acme Ltd' }, total: 1234.5 },
filename: 'invoice-042',
response: 'url',
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.url, body.expires_at);import requests
res = requests.post(
"https://api.castpdf.com/v1/pdf",
headers={"Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": "invoice-INV-042"},
json={
"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
"data": {"number": "INV-042", "customer": {"name": "Acme Ltd"}, "total": 1234.5},
"filename": "invoice-042",
"response": "url",
},
timeout=60,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["url"], body["expires_at"]){
"id": "3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3",
"status": "succeeded",
"pages": 2,
"bytes": 48213,
"mode": "print",
"test": false,
"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
"template_version": 4,
"error_code": null,
"created_at": "2026-09-30T10:15:02.123Z",
"expires_at": "2026-10-07T10:15:02.123Z",
"url": "https://api.castpdf.com/v1/files/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3?exp=1791368102&sig=Jx0k...",
"thumbnail_url": "https://api.castpdf.com/v1/thumbnails/documents/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3.png?exp=1790935200&sig=Qm4t...",
"filename": "invoice-042.pdf",
"metadata": null,
"delivery": null,
"blocked_resources": [],
"warnings": []
}{
"error": {
"code": "template_render_error",
"message": "Template error: undefined filter: moneyy",
"docs_url": "https://castpdf.com/docs/errors#template_render_error",
"details": { "line": 12, "column": 5 }
}
}Send exactly one of template_id or html, or neither when you send e_invoice (the built-in invoice template renders it).
| Field | Description |
|---|---|
template_idstring (UUID) | A stored template to render. |
template_versioninteger, 1 or more | With template_id: render this version. Default: the pinned version, or else the current one. |
htmlstring, not empty | HTML to render: a fragment or a full document. With data it is a Liquid template and may be at most 500 KB (larger is template_render_error). |
cssstring | With html only: added as a stylesheet before the HTML. A template uses its own CSS, so css together with template_id is rejected with invalid_request; edit the template's CSS instead. css without html is rejected too. |
dataobject | The Liquid data. With template_id it defaults to the template's sample_data. With html, the HTML is rendered as a Liquid template only when data is present; without it, the HTML is used as it is. |
merge_dataobject | For form builders in no-code tools: the values of a template's fields as nested objects. Empty values (null, "", empty objects and lists) are dropped, and the rest is merged deeply over data. Keys are used exactly as given, never as paths. |
mode"fast" or "print" | Default: the template's setting, else print for templates and fast for HTML. See fast and print mode. |
format"A4", "A5", "Letter", "Legal", or an object with width and height | Paper size. Default: the template's setting, else A4. Custom sizes are lengths such as "210mm". |
orientation"portrait" or "landscape" | Default: the template's setting, else portrait. Applies to the named formats only. |
marginsstring | One to four lengths separated by spaces, like CSS margin: "20mm", "15mm 20mm", "1in 0.75in 1in 0.75in". Units: mm, cm, in, px, pt (or a bare 0). Default: the template's setting, else 20mm. |
filenamestring, up to 100 characters | The name in content-disposition. Characters other than letters, digits, ., _ and - become -, and .pdf is added. Default: the template's name (the invoice number for an e-invoice), or document. |
response"binary" or "url" | binary (default) returns the PDF; url returns JSON with a signed download link. |
metadataobject | Your own key-value data (at most 20 keys and 8 KB as JSON), stored with the document for 30 days. The url response and webhook events carry it; GET /v1/pdf/:id and the document log do not. |
asyncboolean | true queues the render and answers 202 at once. See background rendering. |
deliverobject | { "email": { "to", "subject", "message", "reply_to" } } emails the finished PDF to one address. The rules, limits and the delivery status are in email delivery. |
testboolean | true makes a test document even with a live key: watermarked, not counted, test webhooks. A test key always makes test documents. See test and live mode. |
e_invoiceobject | { "standard", "profile", "invoice" }: a structured invoice to issue as Factur-X or ZUGFeRD (a PDF/A-3b with the XML embedded) or as XRechnung (the XML itself). data, template_id and html become optional. The full reference is the e_invoice field. |
| Header | Description |
|---|---|
Idempotency-Key | Optional. 1 to 255 printable ASCII characters without spaces. Repeating a request with the same key returns the first request's document instead of rendering (and counting) a second one. See idempotency. |
The HTML after Liquid, with the CSS, may be at most 5 MB. Page options you set in the request override the template's settings.
The binary response
200 with the PDF as the body (response: "binary", the default):
HTTP/1.1 200 OK
content-type: application/pdf
content-disposition: attachment; filename="invoice-042.pdf"
x-document-id: 3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3
x-pages: 2
x-blocked-resources: 0
x-warnings: []
cache-control: no-store| Header | Description |
|---|---|
content-type | application/pdf, or application/xml for an XRechnung e-invoice |
content-disposition | attachment with the file name |
x-document-id | The document's id, for GET /v1/pdf/:id |
x-pages | The page count |
x-blocked-resources | How many blocked external requests are reported (0 to 10; see external resources) |
x-warnings | The warnings as a JSON array, [] when there are none |
x-e-invoice | Only for an e-invoice: standard/profile, such as factur-x/en16931 |
With a test key, a binary response without an Idempotency-Key is not stored: nothing could fetch it later. The document still appears in the log, with url: null. A live binary response is stored too, but when your team's stored-PDF limit is full the PDF is still returned, just without a stored copy.
The url response
200 with JSON (response: "url"), as in the example above. The PDF is stored for your plan's retention period (test documents: 1 day), and url is a signed link that works without an API key until expires_at. The JSON has every field of the document object, plus:
| Field | Description |
|---|---|
filenamestring | The file name, as in content-disposition. |
metadataobject or null | The metadata you sent. |
blocked_resourcesarray of strings | Up to 10 external requests the renderer refused, each with its reason, for example "http://10.0.0.5/logo.png (address 10.0.0.5:80)". |
warningsarray | The warnings. |
e_invoiceobject | Only for an e-invoice: { "standard", "profile", "xml_filename" }. |
A replayed Idempotency-Key request reports no blocked resources (x-blocked-resources: 0, blocked_resources: []) and no warnings, even if the first response had some: they are not stored with the document.
Example: HTML and CSS to a PDF
From HTML and CSS, returning the PDF itself (A5, landscape, print mode):
curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Certificate</h1><p>Awarded to {{ name }}</p>",
"css": "h1 { font-size: 32pt; text-align: center; }",
"data": {"name": "Ada Lovelace"},
"format": "A5",
"orientation": "landscape",
"margins": "15mm",
"mode": "print"
}' \
--output certificate.pdfimport { writeFile } from 'node:fs/promises';
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
html: '<h1>Certificate</h1><p>Awarded to {{ name }}</p>',
css: 'h1 { font-size: 32pt; text-align: center; }',
data: { name: 'Ada Lovelace' },
format: 'A5',
orientation: 'landscape',
margins: '15mm',
mode: 'print',
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile('certificate.pdf', Buffer.from(await res.arrayBuffer()));import requests
res = requests.post(
"https://api.castpdf.com/v1/pdf",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"html": "<h1>Certificate</h1><p>Awarded to {{ name }}</p>",
"css": "h1 { font-size: 32pt; text-align: center; }",
"data": {"name": "Ada Lovelace"},
"format": "A5",
"orientation": "landscape",
"margins": "15mm",
"mode": "print",
},
timeout=60,
)
res.raise_for_status()
with open("certificate.pdf", "wb") as f:
f.write(res.content)Set your HTTP client's timeout to at least 60 seconds: a render may take up to 30 seconds, plus time in the queue when the service is busy.
Errors
| Code | When |
|---|---|
invalid_request (400) | Validation failed, both or neither of template_id and html, css together with template_id or without html, metadata too large, deliver together with an XRechnung, or a malformed Idempotency-Key |
invalid_e_invoice (400) | e_invoice is not a valid e-invoice; details.issues lists every problem. See the e_invoice field |
template_not_found (404) | The template or the requested version does not exist |
template_render_error (422) | Liquid failed, or html sent with data is over 500 KB; details has line and column when known |
document_too_large (413) | The HTML after Liquid is over 5 MB, the PDF would have more than 50 pages, or the PDF would be larger than 40 MB |
payload_too_large (413) | The request body is over 5 MB |
plan_limit_reached, spending_cap_reached (402) | Live documents only; see usage and billing |
email_not_verified (403), email_limit_reached (429) | With deliver.email; see email delivery |
idempotency_conflict (409), idempotency_mismatch (422), file_expired (410) | See idempotency |
storage_limit_reached (429) | The PDF must be stored (a url response, an email, or a request with an Idempotency-Key) and your team's stored PDFs are at their limit |
rate_limited (429) | Over the key's rate limit, or every concurrent render slot of your team is busy (details.reason: "concurrency"); see rate limits |
render_failed (422), render_timeout (504), service_busy (503) | Rendering failed, took longer than 30 seconds, or the service is busy |
content_lost (422) | Print mode only: part of the document could not fit on a page and would be cut off, so no PDF was made |
All codes, including internal_error, are on the errors page.
Background rendering
With "async": true the request returns at once, which suits automation tools with short timeouts and pages that should not wait:
curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b", "data": {"number": "INV-042"}, "async": true}'{ "id": "3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3", "status": "queued", "created_at": "2026-10-03T10:15:02.123Z" }The answer is 202 with a Location header pointing at GET /v1/pdf/:id. The id is the future document's id. Until the PDF exists, GET /v1/pdf/:id shows status: "queued" or "processing"; then it shows the document as usual. When it is done, a pdf.created or pdf.failed webhook is sent, so you do not have to poll. A missing template and an invalid e_invoice are reported right away (404, 400); everything that happens while rendering is reported in pdf.failed.
- The PDF is always stored (a background PDF can only be fetched later) and, with a live key, counted once like any other document.
responseis ignored. - Background PDFs use your workspace's concurrent render slots too, and leave one free for direct requests when your plan has more than one. On the Free plan (one slot) a running background PDF takes the only slot, so a direct request at the same moment gets
rate_limitedwithdetails.reason: "concurrency"and should retry after a second. - A workspace can have Free 50, Starter 1,000, Growth 2,000, Pro 5,000 and Scale 10,000 background PDFs waiting or rendering at once (twice its batch size), and can queue Free 100, Starter 2,000, Growth 4,000, Pro 10,000 and Scale 20,000 test-mode background PDFs in 24 hours. Above that, new background work gets
rate_limited(429) withdetails.limitopen_jobsortest_jobs_per_day. - A render that times out or meets a busy service is tried again, 3 attempts in all (waits of 30 seconds and 2 minutes). Other errors fail at once.
- An
Idempotency-Keyworks as for direct requests: the same key and body within 24 hours return the first request's id. - Queued work survives restarts of our servers: it continues where it stopped, without making or counting a PDF twice.
Warnings
A PDF that was made but deserves a look comes with warnings (in the JSON response, and as the x-warnings header of a binary one). They never fail the request, and there are at most 20:
"warnings": [
{ "code": "missing_variable", "path": "customer.vat_id" },
{ "code": "missing_variable", "path": "items[].discount" },
{ "code": "overflow", "selector": "td.desc", "page": 2 }
]missing_variable: the template prints{{ customer.vat_id }}, but your data has no such field, so that spot of the PDF is empty. In a loop the path names the list:items[].discountmeans at least one item has nodiscount. Values you test with{% if %}, print with adefaultfilter, or send asnullare not reported.overflow(print mode): content runs past the left or right edge of a page and is cut off sideways, usually a long word or URL in a table cell.selectoris the element's tag and up to two classes;pageis the page number.
Warnings name fields and elements of your template, never your data. The x-warnings header is kept under 4 KB, so on a binary response a warning that does not fit is left out; the JSON of a url response has them all. The dashboard's Activity log keeps them, with the status, timing and page count of every render, for 30 days.
GET /v1/pdf/:id
Returns a document's record and, while the PDF is stored, a freshly signed download link. Test keys can read test documents only; a live document requested with a test key is document_not_found.
curl https://api.castpdf.com/v1/pdf/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3 \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch('https://api.castpdf.com/v1/pdf/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3', {
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
const doc = await res.json();
console.log(doc.status, doc.url);import requests
res = requests.get(
"https://api.castpdf.com/v1/pdf/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3",
headers={"Authorization": "Bearer YOUR_API_KEY"},
timeout=30,
)
doc = res.json()
print(doc["status"], doc["url"]){
"id": "3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3",
"status": "succeeded",
"pages": 2,
"bytes": 48213,
"mode": "print",
"test": false,
"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
"template_version": 4,
"error_code": null,
"created_at": "2026-09-30T10:15:02.123Z",
"expires_at": "2026-10-07T10:15:02.123Z",
"url": "https://api.castpdf.com/v1/files/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3?exp=1791368102&sig=Jx0k...",
"thumbnail_url": "https://api.castpdf.com/v1/thumbnails/documents/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3.png?exp=1790935200&sig=Qm4t...",
"delivery": null
}{
"id": "3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3",
"status": "queued",
"pages": null,
"bytes": null,
"mode": null,
"test": false,
"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
"template_version": null,
"error_code": null,
"created_at": "2026-10-03T10:15:02.123Z",
"expires_at": null,
"url": null,
"delivery": null
}{
"error": {
"code": "document_not_found",
"message": "Document 3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3 not found",
"docs_url": "https://castpdf.com/docs/errors#document_not_found"
}
}The response has cache-control: no-store, because it carries a signed link.
The document object
GET /v1/pdf/:id and every item of GET /v1/documents have these fields:
| Field | Description |
|---|---|
idstring (UUID) | The document's id. |
statusstring | succeeded, or failed when rendering failed; error_code then holds the error code, and pages and bytes are null. Requests rejected before rendering (validation, template errors, plan limits) create no document. A background PDF or batch item that has not finished shows queued or processing, and a canceled batch item canceled. |
pages, bytesinteger or null | The page count and the file size. |
modestring or null | fast or print; null while queued. |
testboolean | true for test documents (a test key, or test: true). |
template_id, template_versionstring, integer, or null | The template and version rendered, or null for HTML. |
error_codestring or null | Why a failed document failed. |
error_messagestring | Only on a background PDF that failed before rendering (for example a template error or a plan limit), which has no document record: the error in words. |
created_atstring | When the document was made (or queued). |
expires_atstring or null | When the stored PDF is deleted, or null if it was never stored. |
urlstring or null | A signed link valid until expires_at, or null once the file expired or if it was never stored. |
thumbnail_urlstring or null | A signed PNG of the first page while the PDF is stored, else null. Not on a document that is still queued. |
deliveryobject or null | null, or { "email": { "status": "queued" | "sent" | "failed" | "suppressed" } } when the PDF was emailed. A failed email also has a reason: over_limit (not sent, over a limit), not_stored (no stored PDF to send) or send_failed (every retry failed). |
| Code | When |
|---|---|
document_not_found (404) | No document or queued PDF with this id in your workspace, or a live document read with a test key |
invalid_request (400) | The id is not a UUID |
Document records are kept for 120 days; the PDF itself only for your plan's retention period. See usage and billing for retention per plan.