Skip to content
CastPDF
Get started

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
npm install @castpdf/sdk

The 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
pip install castpdf

The 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).

Create a client
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 test
from 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 test

Keep 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.jsPythonWhat 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.

Create a PDF
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.

Queue and wait
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.

Templates
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 unpins
templates = 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 unpins

Templates, 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.

Webhook handler
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 "", 200

Signatures 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_limited answer is retried after the Retry-After wait: 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, PUT and DELETE. A POST is retried only when it carries an Idempotency-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.
Retries and idempotency keys
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.

ClassStatus
BadRequestError400, 422
AuthenticationError401
PaymentRequiredError402
PermissionDeniedError403
NotFoundError404
ConflictError409
GoneError410
PayloadTooLargeError413
RateLimitError429, with the wait in seconds (retryAfter or retry_after)
InternalServerError5xx
APIConnectionError, APITimeoutErrorno answer at all
Handle errors
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 line

Next steps

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