Skip to content
CastPDF
API reference

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.

Request
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"])
Response
{
  "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).

FieldDescription
template_idstring (UUID)A stored template to render.
template_versioninteger, 1 or moreWith template_id: render this version. Default: the pinned version, or else the current one.
htmlstring, not emptyHTML 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).
cssstringWith 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.
dataobjectThe 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_dataobjectFor 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 heightPaper 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.
marginsstringOne 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 charactersThe 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.
metadataobjectYour 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.
asyncbooleantrue 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.
testbooleantrue 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.
HeaderDescription
Idempotency-KeyOptional. 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):

200
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
HeaderDescription
content-typeapplication/pdf, or application/xml for an XRechnung e-invoice
content-dispositionattachment with the file name
x-document-idThe document's id, for GET /v1/pdf/:id
x-pagesThe page count
x-blocked-resourcesHow many blocked external requests are reported (0 to 10; see external resources)
x-warningsThe warnings as a JSON array, [] when there are none
x-e-invoiceOnly 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:

FieldDescription
filenamestringThe file name, as in content-disposition.
metadataobject or nullThe metadata you sent.
blocked_resourcesarray of stringsUp 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)".
warningsarrayThe warnings.
e_invoiceobjectOnly 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):

Request
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.pdf
import { 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

CodeWhen
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
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}'
202
{ "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. response is 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_limited with details.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) with details.limit open_jobs or test_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-Key works 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
"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[].discount means at least one item has no discount. Values you test with {% if %}, print with a default filter, or send as null are 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. selector is the element's tag and up to two classes; page is 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.

Request
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"])
Response
{
  "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:

FieldDescription
idstring (UUID)The document's id.
statusstringsucceeded, 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 nullThe page count and the file size.
modestring or nullfast or print; null while queued.
testbooleantrue for test documents (a test key, or test: true).
template_id, template_versionstring, integer, or nullThe template and version rendered, or null for HTML.
error_codestring or nullWhy a failed document failed.
error_messagestringOnly 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_atstringWhen the document was made (or queued).
expires_atstring or nullWhen the stored PDF is deleted, or null if it was never stored.
urlstring or nullA signed link valid until expires_at, or null once the file expired or if it was never stored.
thumbnail_urlstring or nullA signed PNG of the first page while the PDF is stored, else null. Not on a document that is still queued.
deliveryobject or nullnull, 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).
CodeWhen
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.

Updated Something unclear? [email protected], a real person replies.