SDKs for Node.js and Python
Install the official CastPDF SDK for Node.js or Python, create PDFs, wait for background renders, read template fields, verify webhooks and handle errors.
On this page
CastPDF has two official SDKs: @castpdf/sdk for Node.js and TypeScript, and castpdf for Python. Both wrap the same HTTP API, so every option on this page is a field you can also send with plain HTTP. They add typed requests and answers, retries with backoff, idempotency keys, typed errors and a helper that checks webhook signatures. Both are version 0.1.0 and MIT licensed.
You do not need an SDK. Any HTTP client works, and the Node.js and Python pages show the raw request next to the SDK call. Use whichever reads better in your codebase.
Install
Node.js
npm install @castpdf/sdkThe package is @castpdf/sdk on npm, published with provenance from github.com/castpdf/castpdf-node. It needs Node.js 20.3 or newer, or another runtime with fetch, AbortSignal.any and Web Crypto, such as Deno or Bun. It has no runtime dependencies, and works with import and with require. Its types are generated from the public OpenAPI description.
Python
pip install castpdfThe package is castpdf on PyPI, from github.com/castpdf/castpdf-python. It needs Python 3.10 or newer and is built on httpx. It has a sync client (CastPDF) and an asyncio client (AsyncCastPDF) with the same methods, and type hints throughout.
Authenticate
Create an API key in the dashboard under Connect, then API (authentication). Keep it in the CASTPDF_API_KEY environment variable: both clients read it when you pass no key. A cpdf_test_ key makes free, watermarked test PDFs that never count; a cpdf_live_ key makes real ones (test and live mode).
import CastPDF from '@castpdf/sdk';
// reads CASTPDF_API_KEY, or pass { apiKey: 'cpdf_test_...' }
const castpdf = new CastPDF();
const me = await castpdf.me(); // the team, the plan and the key mode: a cheap connection testfrom castpdf import CastPDF
client = CastPDF() # reads CASTPDF_API_KEY, or CastPDF("cpdf_test_...")
me = client.me() # the team, the plan and the key mode: a cheap connection testKeep the key on your server. Never ship it in code that runs in a browser or a mobile app. With CommonJS, write const { CastPDF } = require('@castpdf/sdk'). In Python, with CastPDF() as client: closes the connection pool when you are done.
Create a PDF
There is one method per kind of answer, all on POST /v1/pdf (PDFs in the API reference):
| Node.js | Python | What you get |
|---|---|---|
pdfs.create(params) | pdfs.create(**params) | JSON with a signed url, valid until expires_at (the document is stored) |
pdfs.createFile(params) | pdfs.create_file(**params) | The bytes, with the file name, document id, page count and warnings |
pdfs.createAsync(params) | pdfs.create_async(**params) | At once, a queued document id; the PDF follows (below) |
The parameters are the fields of the request body: template_id and data, or html (with optional css and data), plus format, orientation, margins, mode, filename, metadata, deliver and the rest. test: true makes a test PDF even with a live key.
import { writeFile } from 'node:fs/promises';
// a saved template filled with data, as a private download link
const pdf = await castpdf.pdfs.create({
template_id: process.env.INVOICE_TEMPLATE_ID,
data: { customer: { name: 'Ada Lovelace' }, items: [{ name: 'Consulting', qty: 2, price: 120 }] },
test: true,
});
console.log(pdf.url, pdf.expires_at);
// raw HTML with Liquid, straight to a file
const file = await castpdf.pdfs.createFile({
html: '<h1>Hello {{ name }}</h1>',
data: { name: 'Ada' },
format: 'A4',
margins: '20mm',
filename: 'hello.pdf',
});
await writeFile(file.filename ?? 'out.pdf', file.data);import os
from pathlib import Path
# a saved template filled with data, as a private download link
pdf = client.pdfs.create(
template_id=os.environ["INVOICE_TEMPLATE_ID"],
data={"customer": {"name": "Ada Lovelace"}, "items": [{"name": "Consulting", "qty": 2, "price": 120}]},
test=True,
)
print(pdf["url"], pdf["expires_at"])
# raw HTML with Liquid, straight to a file
f = client.pdfs.create_file(html="<h1>Hello {{ name }}</h1>", data={"name": "Ada"}, format="A4", margins="20mm")
Path(f.filename or "out.pdf").write_bytes(f.content)The same methods issue EU e-invoices: add e_invoice to the parameters (e-invoicing guide). Factur-X and ZUGFeRD come back as a PDF/A-3b with the XML inside; XRechnung comes back as the XML itself, with the content type application/xml.
Background rendering and waiting
For long documents, or when your own request has a short time limit, queue the render and carry on. The answer arrives as a pdf.created or pdf.failed webhook, or you poll for it. waitFor and wait_for poll every second until the document has succeeded, failed or been canceled, and give up after 120 seconds unless you set another limit.
const queued = await castpdf.pdfs.createAsync({ template_id, data, metadata: { order: '1042' } });
const doc = await castpdf.pdfs.waitFor(queued.id, { intervalMs: 2000, timeoutMs: 180_000 });
if (doc.status === 'succeeded') console.log(doc.url);
else console.log(doc.status, doc.error_code);
// later: the same document with a fresh signed url while it is stored
const again = await castpdf.pdfs.get(doc.id);queued = client.pdfs.create_async(template_id=template_id, data=data, metadata={"order": "1042"})
doc = client.pdfs.wait_for(queued["id"], interval=2.0, timeout=180.0)
if doc["status"] == "succeeded":
print(doc["url"])
else:
print(doc["status"], doc.get("error_code"))
# later: the same document with a fresh signed url while it is stored
again = client.pdfs.get(doc["id"])documents.list reads one page of the document log, newest first, and documents.iterate walks every page for you (documents in the API reference).
Templates and their fields
A template's fields are read from its sample data, so they tell you exactly what to send as data. fields returns each value's dotted key (such as customer.name), its type and its sample value, and each group of line items with the fields of one item. preview renders a watermarked preview that never counts.
const { data: templates } = await castpdf.templates.list();
const fields = await castpdf.templates.fields(templates[0].id);
for (const f of fields.fields) console.log(f.key, f.type, f.sample);
const preview = await castpdf.templates.preview(templates[0].id, { data: { number: 'RE-1' } });
// live keys only: a new template, then a new version of it
const t = await castpdf.templates.create({ name: 'Invoice', html: '<h1>{{ number }}</h1>', sample_data: { number: 'RE-1' } });
await castpdf.templates.update(t.id, { html: '<h1>Invoice {{ number }}</h1>' });
await castpdf.templates.pin(t.id, 2); // renders use version 2; null unpinstemplates = client.templates.list()["data"]
fields = client.templates.fields(templates[0]["id"])
for f in fields["fields"]:
print(f["key"], f["type"], f["sample"])
preview = client.templates.preview(templates[0]["id"], data={"number": "RE-1"})
# live keys only: a new template, then a new version of it
t = client.templates.create(name="Invoice", html="<h1>{{ number }}</h1>", sample_data={"number": "RE-1"})
client.templates.update(t["id"], html="<h1>Invoice {{ number }}</h1>")
client.templates.pin(t["id"], 2) # renders use version 2; None unpinsTemplates, versions and pinning are explained in templates and versions. Batches (batches.create, batches.get, batches.iterateItems or iterate_items) follow the same pattern (batches).
Verify webhook signatures
CastPDF signs every webhook delivery with the CastPDF-Signature header: t= (Unix seconds) and v1= (the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the endpoint's secret). constructEvent and construct_event check it and return the parsed event, or throw SignatureVerificationError. Pass the raw body exactly as it arrived: a body parsed and serialized again no longer matches.
import express from 'express';
import { constructEvent, SignatureVerificationError } from '@castpdf/sdk';
const app = express();
app.post('/castpdf', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await constructEvent({
payload: req.body, // a Buffer with the raw body
header: req.get('castpdf-signature'),
secret: process.env.CASTPDF_WEBHOOK_SECRET,
});
if (event.type === 'pdf.created') console.log('ready:', event.data.url);
res.sendStatus(200);
} catch (err) {
if (err instanceof SignatureVerificationError) return res.sendStatus(400);
throw err;
}
});import os
from flask import Flask, abort, request
from castpdf import SignatureVerificationError, construct_event
app = Flask(__name__)
@app.post("/castpdf")
def castpdf_webhook():
try:
event = construct_event(
request.get_data(), # the raw bytes
request.headers.get("CastPDF-Signature"),
os.environ["CASTPDF_WEBHOOK_SECRET"],
)
except SignatureVerificationError:
abort(400)
if event["type"] == "pdf.created":
print("ready:", event["data"]["url"])
return "", 200Signatures older than 5 minutes are refused (toleranceSeconds or tolerance_seconds changes that). For 24 hours after you rotate a secret the header carries two v1 values, and either secret verifies. verifySignature and verify_signature return true or false instead of throwing. Events, retries and the delivery log are on webhooks and signatures.
Retries, idempotency and timeouts
- A
429 rate_limitedanswer is retried after theRetry-Afterwait: the request was never processed. Daily limits (storage_limit_reached,email_limit_reached) are not retried. - 5xx answers, timeouts and network errors are retried for
GET,PUTandDELETE. APOSTis retried only when it carries anIdempotency-Key, so a retry never renders, or bills, a PDF twice. - Waits grow exponentially with jitter, from about half a second up to 8 seconds. Each client retries twice by default.
- Each attempt may take 120 seconds by default. A render itself is limited to 30 seconds.
const castpdf = new CastPDF({
maxRetries: 3,
timeoutMs: 60_000, // per attempt
autoIdempotencyKeys: true, // a generated key on pdfs.create* and batches.create when you give none
});
await castpdf.pdfs.create({ template_id, data }, { idempotencyKey: `invoice-${order.id}`, timeoutMs: 30_000 });client = CastPDF(
max_retries=3,
timeout=60.0, # seconds per attempt
auto_idempotency_keys=True, # a generated key on pdfs.create* and batches.create when you give none
)
client.pdfs.create(template_id=template_id, data=data, idempotency_key=f"invoice-{order_id}")
client.me(request_options={"timeout": 10, "max_retries": 0})The same key with the same body within 24 hours returns the first result instead of rendering again (idempotency). A request with a key is always stored by the API so it can be replayed, also when you asked for the file. Use a key that names the document in your system, such as the invoice number.
Errors
Every error extends CastPDFError. An answer with an error status raises an APIError subclass with the status, the code (such as template_not_found), the message, the docs link (docsUrl or docs_url), the details and the request id. The codes are listed on errors.
| Class | Status |
|---|---|
BadRequestError | 400, 422 |
AuthenticationError | 401 |
PaymentRequiredError | 402 |
PermissionDeniedError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
GoneError | 410 |
PayloadTooLargeError | 413 |
RateLimitError | 429, with the wait in seconds (retryAfter or retry_after) |
InternalServerError | 5xx |
APIConnectionError, APITimeoutError | no answer at all |
import { NotFoundError, RateLimitError, BadRequestError } from '@castpdf/sdk';
try {
await castpdf.pdfs.create({ template_id, data });
} catch (err) {
if (err instanceof NotFoundError) console.log('No such template');
else if (err instanceof RateLimitError) console.log(`Retry in ${err.retryAfter} s`);
else if (err instanceof BadRequestError) console.log(err.code, err.details); // a Liquid error names its line
else throw err;
}from castpdf import BadRequestError, NotFoundError, RateLimitError
try:
client.pdfs.create(template_id=template_id, data=data)
except NotFoundError:
print("No such template")
except RateLimitError as err:
print(f"Retry in {err.retry_after} s")
except BadRequestError as err:
print(err.code, err.details) # a Liquid error names its lineNext steps
- Node.js page and Python page: a full walkthrough with the SDK and with raw HTTP.
- API reference: every field the SDK methods accept.
- MCP server: the same API from AI agents that support the Model Context Protocol.
- Rate limits: the per-minute limits the retries respect.