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.
Before you start
Section titled “Before you start”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.
How it works
Section titled “How it works”-
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.
-
Call
renderwith 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. -
Ask for the bytes inline with
includeDocument.By default the response carries metadata only and the PDF stays server-side. Pass
includeDocument: trueand the PDF rides along as Base64, which the SDK decodes for you. Without it,save()has nothing to write. -
Check
result.okbefore touchingresult.document.A document that fails validation is a normal outcome, not an exception:
result.okisfalse,result.documentisnull, andresult.issuesexplains why. Only protocol failures raise. -
Save it.
Pass a directory and the SDK names the file from
documentName, appending.pdf. Pass a full path and it writes exactly there.
Full example
Section titled “Full example”curl -X POST "https://pagr-prd-api-public.azurewebsites.net/v1/render/8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90" \ -H "Authorization: Bearer pagr_test_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "documents": [ { "Title": "Acme Q3 Invoice", "Amount": 42 } ], "includeDocument": true }' | jq -r '.documents[0].documentBase64' | base64 -d > invoice.pdfRaw HTTP gives you Base64 in a JSON field — you decode it yourself. To skip
that step entirely, send Accept: application/pdf instead and write the body
straight to a file: see Render a raw PDF.
import asynciofrom 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())import { PagrApiClient, PagrError } from 'pagr';
const TEMPLATE_ID = '8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90';
const client = new PagrApiClient('pagr_test_xxxxxxxx');
try { const result = await client.render( TEMPLATE_ID, { Title: 'Acme Q3 Invoice', Amount: 42 }, { includeDocument: true }, // ← without this, save() has nothing to write );
if (!result.ok) { // business outcome console.log(result.status, result.message); for (const issue of result.issues) console.log(' ', issue.description); } else { const doc = result.document!; console.log(`${doc.documentName} — ${doc.pageCount} page(s), ${doc.fileSizeBytes} bytes`); console.log('Wrote', await doc.save('./out')); }} catch (err) { // protocol failures only if (err instanceof PagrError) console.log('Request failed:', err.message); else throw err;}Node.js only — save() uses node:fs/promises, so it won’t run in a browser bundle.
import org.example.PagrApiClient;import org.example.RenderOptions;import org.example.exception.PagrException;import org.example.models.RenderResult;import java.nio.file.Path;import java.util.Map;import java.util.UUID;
UUID templateId = UUID.fromString("8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90");
try (PagrApiClient client = new PagrApiClient("pagr_test_xxxxxxxx")) {
RenderResult result = client.render( templateId, Map.of("Title", "Acme Q3 Invoice", "Amount", 42), RenderOptions.builder().includeDocument(true).build()); // ← required to save
if (!result.isOk()) { // business outcome System.out.println(result.getStatus() + " " + result.getMessage()); result.getIssues().forEach(i -> System.out.println(" " + i)); } else { var doc = result.getDocument(); System.out.printf("%s — %d page(s), %d bytes%n", doc.getDocumentName(), doc.getPageCount(), doc.getFileSizeBytes()); System.out.println("Wrote " + doc.save(Path.of("out"))); }} catch (PagrException exc) { // protocol failures only System.out.println("Request failed: " + exc.getMessage());}A separate PagrAsyncApiClient mirrors every call, returning CompletableFuture<T>.
using Pagr.Sdk;using Pagr.Sdk.Exceptions;
var templateId = Guid.Parse("8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90");
// The client owns a pooled HttpClient — create one and reuse it.using var client = new PagrApiClient("pagr_test_xxxxxxxx");
try{ var result = await client.RenderAsync( templateId, new { Title = "Acme Q3 Invoice", Amount = 42 }, includeDocument: true); // ← without this, SaveAsync has nothing to write
if (!result.Ok) // business outcome { Console.WriteLine($"{result.Status} {result.Message}"); foreach (var issue in result.Issues) Console.WriteLine($" {issue}"); } else { var doc = result.Document!; Console.WriteLine($"{doc.DocumentName} — {doc.PageCount} page(s), {doc.FileSizeBytes} bytes"); Console.WriteLine($"Wrote {await doc.SaveAsync("out")}"); }}catch (PagrApiException exc) // protocol failures only{ Console.WriteLine($"Request failed: {exc.Message}");}require "pagr"
TEMPLATE_ID = "8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90"
client = Pagr::Client.new("pagr_test_xxxxxxxx")
begin result = client.render( TEMPLATE_ID, { "Title" => "Acme Q3 Invoice", "Amount" => 42 }, include_document: true, # ← without this, save has nothing to write )
if result.ok? doc = result.document puts "#{doc.document_name} — #{doc.page_count} page(s), #{doc.file_size_bytes} bytes" puts "Wrote #{doc.save('out')}" # existing directory → out/<name>.pdf else puts "#{result.status} #{result.message}" result.issues.each { |issue| puts " #{issue}" } endrescue Pagr::Error => e # protocol failures only warn "Request failed: #{e.message}"end#include <iostream>#include "pagr/PagrApiClient.hpp"#include "pagr/exceptions.hpp"
const std::string kTemplateId = "8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90";
pagr::PagrApiClient client("pagr_test_xxxxxxxx");
try { const auto result = client.render( kTemplateId, R"({"Title": "Acme Q3 Invoice", "Amount": 42})", {.include_document = true}); // ← without this, save() has nothing to write
if (!result.ok()) { // business outcome std::cout << result.status << "\n"; for (const auto& issue : result.issues) { std::cout << " " << issue.description << "\n"; } } else { const auto& doc = *result.document; std::cout << doc.document_name << " — " << doc.page_count << " page(s)\n"; doc.save("out/" + doc.document_name + ".pdf"); }} catch (const pagr::PagrApiException& exc) { // protocol failures only std::cout << "Request failed: " << exc.what() << "\n";}Every call also has a _async form (render_async) returning std::future<T>.
Variation: don’t store the document
Section titled “Variation: don’t store the document”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.
curl -X POST ".../v1/render/{templateId}?persist=false" \ -H "Authorization: Bearer pagr_test_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "documents": [ { "Title": "Preview" } ] }'result = await client.render(TEMPLATE_ID, data, persist=False)# include_document is unnecessary — the bytes are forced inlinepdf_bytes = result.document.to_bytes()assert result.document.id is None and result.document.view_url is Noneconst result = await client.render(TEMPLATE_ID, data, { persist: false });const pdfBytes = result.document!.toBytes();// result.document.id and .viewUrl are null — nothing was storedRenderResult result = client.render(templateId, data, RenderOptions.builder().persist(false).build());byte[] pdf = result.getDocument().toBytes();var result = await client.RenderAsync(templateId, data, persist: false);byte[] pdf = result.Document!.ToBytes();result = client.render(TEMPLATE_ID, data, persist: false)pdf = result.document.to_bytesconst auto result = client.render(kTemplateId, json_data, {.persist = false});const auto pdf = result.document->to_bytes();Variation: render a specific version
Section titled “Variation: render a specific version”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.
curl -X POST ".../v1/render/{templateId}/versions/3" …result = await client.render(TEMPLATE_ID, data, version=3)const result = await client.render(TEMPLATE_ID, data, { version: 3 });RenderResult result = client.render(templateId, data, RenderOptions.builder().version(3).build());var result = await client.RenderAsync(templateId, data, version: 3);result = client.render(TEMPLATE_ID, data, version: 3)const auto result = client.render(kTemplateId, json_data, {.version = 3});Other considerations
Section titled “Other considerations”documentNameis 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. TODO: how is it sanitized??- When
.pdfgets appended differs slightly by SDK. TODO: SHOULD BE CHANGED; Python, TypeScript, Ruby and C++ check the.pdfsuffix, soInvoice 2024.10becomesInvoice 2024.10.pdf. C# and Java check for any extension / dot, so the same name is left asInvoice 2024.10. Pass an explicit full path when the exact filename matters. - Render output is always PDF.
documentNamecarries 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 anhttpxconnection pool. Create one per process, not one per render.
Related articles
Section titled “Related articles”- Render a document — the field-level reference for this endpoint.
- Render a raw PDF — skip Base64 entirely.
- Validate before rendering — catch data problems for free.
- Render a batch — more than one document per request.
- Handle errors and retries — what raises, what doesn’t.
