Choose a rendering shape
Pagr renders a template three different ways. They share the same template, the same data shape, and the same result model — so picking one is a decision about your workload, not about learning a different API.
Pick one
Section titled “Pick one”| Shape | Use it when | You get back | Endpoint |
|---|---|---|---|
| Single | One document, and you want it now. | A JSON envelope (or raw PDF bytes) | POST /v1/render/{templateId} |
| Synchronous batch | A handful of documents, and the HTTP connection can safely wait for them. | One envelope covering every document | POST /v1/render/{templateId} with an array |
| Async job | A large batch, and you don’t want to hold a connection open. | A jobId; results via webhook or polling |
POST /v1/render/{templateId}/async |
Every shape renders a template stored in Pagr, addressed by its id. There’s no way to render a template supplied inline in the request.
Rules of thumb
Section titled “Rules of thumb”- Under ~10 documents and a user is waiting? Synchronous. The HTTP round trip is simpler than any callback plumbing, and correlation is free.
- Hundreds or thousands of documents? Async job. A synchronous HTTP request that renders 2,000 documents will outlive any sensible network timeout.
- Can’t host a public HTTPS endpoint? Still use an async job — just poll instead of receiving webhooks. Same data, no inbound connection needed.
- Iterating on a template you haven’t published? Address the version
explicitly by number —
POST /v1/render/{templateId}/versions/{version}renders a draft just as happily as a published one. Only the latest-published form (POST /v1/render/{templateId}) needs something published, and answers404 NoPublishedVersionwhen there isn’t. See Versions & publishing.
The three shapes
Section titled “The three shapes”The snippets below use the Python SDK for brevity; every official SDK exposes the same calls under its own naming convention. Each shape’s own guide has all seven languages.
Single
Section titled “Single”One document, one response, no polling.
result = await client.render( template_id, {"Title": "Acme Q3 Invoice", "Amount": 42}, include_document=True,)if result.ok: result.document.save("out/")else: for issue in result.issues: print(issue)Walkthrough: Render and save a PDF · Reference: Render a document
Synchronous batch
Section titled “Synchronous batch”The same endpoint as Single, but you pass a list — the request stays open until every document has rendered (or failed).
result = await client.render_batch( template_id, [ {"Title": "Acme Q3 Invoice", "Amount": 42}, {"Title": "Acme Q4 Invoice", "Amount": 58}, ], include_document=True,)for item in result: # BatchRenderResult is iterable if item.ok: print(item.index, item.document.document_name) else: print(item.index, "failed:", item.issues)Always correlate by item.index, never by list position — a failed document leaves
a gap rather than shifting everything after it. A test key caps a batch at 10
documents; a production key doesn’t.
Walkthrough: Render a batch · Reference: Render a document
Async job
Section titled “Async job”Returns immediately with a queued job. The server renders in the background and calls your webhook as each document finishes, then once more on completion.
job = await client.enqueue_batch_render( template_id, [{"Title": "Acme Q3 Invoice", "Amount": 42}], callback_url="https://your-app.example/pagr/callback?token=s3cr3t",)print(job.job_id, job.state) # RenderJobState.QUEUED
# No public endpoint for the webhook? Poll instead:status = await client.wait_for_job(job.job_id)print(status.state, status.status, status.rendered_count, "/", status.requested_count)A 503 with code QueueFull means the render queue is at capacity — back off and
retry.
Walkthrough: Run renders in the background · Reference: Render a batch asynchronously
Switching between them
Section titled “Switching between them”Because the shapes share a data contract, moving from one to another is a small change, not a rewrite:
| From | To | What changes |
|---|---|---|
| Single | Synchronous batch | Pass a list instead of an object; iterate result instead of reading result.document. |
| Synchronous batch | Async job | Add a callback_url; read the counts off the completion callback or a poll instead of the response. |
| Async webhook | Async polling | Drop the HTTP endpoint; call wait_for_job instead. The parsed model is the same. |
| JSON envelope | Raw PDF bytes | Send Accept: application/pdf; read the metadata off X-Pagr-* headers instead of the envelope. Single document only. |
Other considerations
Section titled “Other considerations”persist=falseis orthogonal to the shape. Any of the three can skip storing the result. The document’sidandviewUrlthen come backnulland the PDF bytes are forced inline — see Render a document → Response.- Test keys watermark and cap. A
pagr_test_key produces watermarked output, doesn’t consume credit, and limits a batch to 10 documents. Swap the key to swap environments — there’s no flag. See Authentication. - One request costs one rate-limit permit, whether it carries 1 document or 500. Batching is the cheapest way to stay under the render limit. See Errors → Rate limits.
- Per-document budgets don’t scale with batch size. Each document gets 50 MB of JSON, 32 levels of nesting, and 60 seconds of render time regardless of how many you submit together.
- Credit can run out mid-batch. The job stops, already-rendered documents are
kept, and
statuscomes backinsufficient_creditwithmissingCountcovering the rest. It is an outcome, not an error — check Organisation stats beforehand if that matters.
