Choose a rendering shape
Pagr renders a template four 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 |
| Stateless | A template that doesn’t live in Pagr. | Raw PDF bytes, nothing stored | POST /v1/render |
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? Stateless, so you don’t publish a version per experiment.
The four shapes
Section titled “The four shapes”The snippets below use the Python SDK for brevity; every official SDK exposes the same four 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) # "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
Stateless
Section titled “Stateless”Renders from a template DSL you supply inline instead of one stored in Pagr. Nothing is persisted — no template, no document, nothing in Renders — and you get the raw PDF bytes back directly rather than a JSON envelope.
pdf_bytes = await client.render_stateless( template=template_dsl, # dict or JSON string — the template DSL itself data={"Title": "Acme Q3 Invoice", "Amount": 42},)with open("out.pdf", "wb") as f: f.write(pdf_bytes)Because the response is the PDF, there is no issues array — a data problem
surfaces as an HTTP error instead. Use a stored template when you need the
diagnostics.
Reference: Render statelessly
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. |
| Stored template | Stateless | Pass the DSL in the body instead of a template_id; lose issues and persistence. |
Other considerations
Section titled “Other considerations”persist=falseis orthogonal to the shape. Any of the first three shapes 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.
