Skip to content

Render and save a PDF

The single most common thing you’ll do with Pagr: take a published template, feed it a JSON object, and end up with a PDF on disk. This guide walks the whole path, including the two decisions that trip people up — whether to get the bytes inline, and whether to store the result.

You need three things:

  • A published template version. A template with nothing published renders as 404 NoPublishedVersion — see Versions & publishing.
  • The template’s id — copy it from the template list or the editor URL.
  • An API key. Use a pagr_test_ key while you build: output is watermarked and consumes no credit. See API Keys.
  1. Construct a client with your API key.

    The base URL defaults to the hosted Pagr API, so the key is the only required argument. The key’s prefix decides test vs. production — there is no separate flag.

  2. Call render with the template id and your data.

    Your data is a plain object whose keys match the template’s bindings. Not sure what those are? Fetch the version’s sampleData — it matches the bindings by construction. See Templates & versions.

  3. Ask for the bytes inline with includeDocument.

    By default the response carries metadata only and the PDF stays server-side. Pass includeDocument: true and the PDF rides along as Base64, which the SDK decodes for you. Without it, save() has nothing to write.

  4. Check result.ok before touching result.document.

    A document that fails validation is a normal outcome, not an exception: result.ok is false, result.document is null, and result.issues explains why. Only protocol failures raise.

  5. Save it.

    Pass a directory and the SDK names the file from documentName, appending .pdf. Pass a full path and it writes exactly there.

import asyncio
from pagr import PagrApiClient, PagrError
TEMPLATE_ID = "8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90"
async def main():
async with PagrApiClient("pagr_test_xxxxxxxx") as client:
try:
result = await client.render(
TEMPLATE_ID,
{"Title": "Acme Q3 Invoice", "Amount": 42},
include_document=True, # ← without this, save() has nothing to write
)
except PagrError as exc: # protocol failures only
print("Request failed:", exc)
return
if not result.ok: # business outcome
print(result.status, result.message)
for issue in result.issues:
print(" ", issue)
return
doc = result.document
print(f"{doc.document_name}{doc.page_count} page(s), {doc.file_size_bytes} bytes")
path = doc.save("out/") # existing directory → out/<name>.pdf
print("Wrote", path)
asyncio.run(main())

Set persist to false when the PDF is transient — a download you stream straight to a user, a preview you throw away. Nothing is stored: the render doesn’t appear in Renders, and id / viewUrl come back null.

result = await client.render(TEMPLATE_ID, data, persist=False)
# include_document is unnecessary — the bytes are forced inline
pdf_bytes = result.document.to_bytes()
assert result.document.id is None and result.document.view_url is None

Rendering defaults to the latest published version, so template updates will immediately affect your output. When strict reproducibility matters (e.g., generating exact copies of past invoices), specify the exact version number in your request.

result = await client.render(TEMPLATE_ID, data, version=3)
  • documentName is data, not a path. It’s generated from the version’s document-name template, so it can embed values bound from your payload — including slashes and dots. Before using it as a filename, every SDK reduces it to a single safe path segment: \ is normalised to /, everything up to the last / is dropped, a Windows drive prefix (C:) is stripped, leading separators and surrounding whitespace are trimmed, and a name that reduces to nothing, . or .. becomes the literal document. So a bound value of ../../etc/passwd can never steer the write outside the directory you chose. Nothing else is rewritten — spaces, accents and punctuation are kept as-is.
  • .pdf is appended on a suffix test, in every SDK. The name gets .pdf unless it already ends in .pdf (case-insensitively) — never a “does it have an extension” test, because a document name routinely contains a literal dot that isn’t one. So Invoice 2024.10 becomes Invoice 2024.10.pdf, and Invoice.PDF is left alone. Pass an explicit full path when the exact filename matters.
  • Render output is always PDF. documentName carries no extension because there’s no other format to distinguish.
  • A slow template needs a bigger timeout, not a retry. The client default is 30 seconds; a document may legitimately take up to the server’s 60-second budget. Pass a per-call timeout rather than raising the client-wide default — see Configure the client. Writes are never retried, so a timed-out render must not be blindly re-sent: it may have rendered and charged already.
  • Warnings block production but not test. A payload that renders on your test key can be rejected on a production key, because production also blocks on Warning-severity issues. See Validate before rendering.
  • Reuse the client. In C# it owns a pooled HttpClient; in Python it owns an httpx connection pool. Create one per process, not one per render.