Skip to content

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.

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
  • 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 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.

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

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

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

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

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.
  • persist=false is orthogonal to the shape. Any of the first three shapes can skip storing the result. The document’s id and viewUrl then come back null and 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 status comes back insufficient_credit with missingCount covering the rest. It is an outcome, not an error — check Organisation stats beforehand if that matters.