Run renders in the background
When a batch is too large to hold an HTTP request open for, enqueue it instead. You
get a jobId back immediately; Pagr renders in the background and tells you what
happened — either by POSTing webhooks to a URL you supply, or by letting you poll.
Both paths deliver the same information in the same parsed shape, so choosing between them is an infrastructure decision, not an API one.
Before you start
Section titled “Before you start”- A published template version and its id.
- An API key. A test key still caps the batch at 10 documents.
- For webhooks: a publicly reachable HTTPS endpoint. If you don’t have one —
local development, a backend with no inbound route — skip to
polling instead. You still pass a
callbackUrl(it’s required), but nothing has to answer it.
How it works
Section titled “How it works”-
Enqueue the batch with a callback URL.
The call returns as soon as the job is queued — it does not wait for any rendering. Payload size and nesting are validated now, so an accepted job never fails later on payload size.
-
Keep the
jobId.It’s how you correlate every callback and every poll back to this submission. Store it alongside whatever business record triggered the render.
-
Receive N + 1 callbacks (for a batch of N documents).
One progress callback per document that renders successfully, then one completion callback when the job reaches a terminal state. Deliveries are retried on failure, so your handler must be idempotent.
-
Parse each callback body with the SDK’s parser.
It inspects the payload’s shape and hands you the right typed object — a progress callback carries a
document, the completion callback doesn’t. -
Correlate progress callbacks by
documentIndex.Documents render in parallel, so callbacks arrive out of input order. Never infer position from arrival order.
-
Read the completion callback for the real answer.
Failed documents produce no progress callback at all — the only place you learn about them is the completion callback’s counts and
issues.
Step 1: enqueue
Section titled “Step 1: enqueue”curl -X POST "https://pagr-prd-api-public.azurewebsites.net/v1/render/8bec66ff-6f3d-4c1e-9a2b-1f0e5d7c4a90/async" \ -H "Authorization: Bearer pagr_prod_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "documents": [ { "Title": "Acme Q3 Invoice", "Amount": 42 }, { "Title": "Acme Q4 Invoice", "Amount": 58 } ], "callbackUrl": "https://your-app.example/pagr/callback?token=s3cr3t" }'# → 202 { "jobId": "6b1c9f42-…", "requestedCount": 2, "state": "queued" }job = await client.enqueue_batch_render( TEMPLATE_ID, documents, callback_url="https://your-app.example/pagr/callback?token=s3cr3t", include_document=False, # True → progress callbacks carry the PDF inline)print(job.job_id, job.requested_count, job.state) # RenderJobState.QUEUEDconst job = await client.enqueueBatchRender( TEMPLATE_ID, documents, 'https://your-app.example/pagr/callback?token=s3cr3t', { includeDocument: false }, // true → progress callbacks carry the PDF inline);console.log(job.jobId, job.requestedCount, job.state); // 'queued'RenderJob job = client.enqueueBatchRender( templateId, documents, "https://your-app.example/pagr/callback?token=s3cr3t");
System.out.println(job.getJobId() + " " + job.getRequestedCount() + " " + job.getState());var job = await client.EnqueueBatchRenderAsync( templateId, documents, callbackUrl: "https://your-app.example/pagr/callback?token=s3cr3t", includeDocument: false); // true → progress callbacks carry the PDF inline
Console.WriteLine($"{job.JobId} {job.RequestedCount} {job.State}");job = client.enqueue_batch_render( TEMPLATE_ID, documents, "https://your-app.example/pagr/callback?token=s3cr3t", include_document: false, # true → progress callbacks carry the PDF inline)puts "#{job.job_id} #{job.requested_count} #{job.state}"const auto job = client.enqueue_batch_render( kTemplateId, documents, "https://your-app.example/pagr/callback?token=s3cr3t");
std::cout << job.job_id << " " << job.requested_count << "\n";Step 2: receive the callbacks
Section titled “Step 2: receive the callbacks”Your endpoint gets a plain JSON POST. Hand the decoded body to the SDK’s parser
and it returns the right typed object.
// Progress — one per successfully rendered document{ "jobId": "6b1c9f42-…", "processed": 1, // completed so far (completion order) "requestedCount": 2, "documentIndex": 1, // ← correlate on THIS, not arrival order "document": { /* rendered document metadata */ }}// Completion — exactly one, at a terminal state{ "jobId": "6b1c9f42-…", "state": "completed", // lifecycle: completed | failed "status": "partial", // outcome: ok | partial | failed | insufficient_credit "renderedCount": 1, "requestedCount": 2, "missingCount": 1, "message": "1 of 2 document(s) rendered; 1 skipped.", "issues": [ /* per-document issues, each with its documentIndex */ ]}Distinguish them by the presence of document: progress callbacks have it,
the completion callback doesn’t.
from pagr import parse_callback, RenderProgress, RenderCompletion
# e.g. inside your FastAPI / Flask / aiohttp handlerasync def handle_callback(payload: dict): callback = parse_callback(payload) # raises PagrDecodeError on a bogus body
if isinstance(callback, RenderProgress): print(f"[{callback.document_index}] {callback.document.document_name}" f" — {callback.progress_pct:.0f}%") else: # RenderCompletion print(f"{callback.state.value} / {callback.status.value}:" f" {callback.rendered_count}/{callback.requested_count}") for issue in callback.issues: print(f" [{issue.document_index}] {issue}")For local development the SDK ships a loopback receiver so you don’t need to stand up a server:
from pagr.webhook import LocalWebhookReceiver # pip install pagr[webhook]
async with LocalWebhookReceiver() as rx: job = await client.enqueue_batch_render(TEMPLATE_ID, documents, rx.url) async for callback in rx: # ends after the completion callback print(callback)import { parseCallback, RenderProgress } from 'pagr';
// e.g. inside your Express / Fastify / Hono handlerfunction handleCallback(payload: unknown) { const callback = parseCallback(payload); // throws PagrDecodeError on a bogus body
if (callback instanceof RenderProgress) { console.log(`[${callback.documentIndex}] ${callback.document.documentName}` + ` — ${callback.progressPct.toFixed(0)}%`); } else { console.log(`${callback.state} / ${callback.status}:` + ` ${callback.renderedCount}/${callback.requestedCount}`); for (const issue of callback.issues) { console.log(` [${issue.documentIndex}] ${issue.description}`); } }}The TypeScript SDK is parser-only — it bundles no receiver server, by
design. Wire parseCallback into whichever HTTP framework you already use.
import com.google.gson.JsonObject;import org.example.webhook.*;
// e.g. inside your Spring / Javalin handlervoid handleCallback(JsonObject payload) { RenderCallback callback = RenderCallback.parse(payload);
if (callback instanceof RenderProgress progress) { System.out.printf("[%d] %s%n", progress.getDocumentIndex(), progress.getDocument().getDocumentName()); } else if (callback instanceof RenderCompletion completion) { System.out.printf("%s / %s: %d/%d%n", completion.getState(), completion.getStatus(), completion.getRenderedCount(), completion.getRequestedCount()); completion.getIssues().forEach(i -> System.out.println(" " + i)); }}RenderCallback is a sealed interface permitting only RenderProgress and
RenderCompletion, so a switch over it is exhaustive.
using Pagr.Sdk.Webhooks;
// e.g. inside your ASP.NET Core endpointvoid HandleCallback(string requestBody){ var callback = RenderCallback.Parse(requestBody); // also accepts a JsonElement
switch (callback) { case RenderProgress progress: Console.WriteLine($"[{progress.DocumentIndex}] {progress.Document.DocumentName}"); break; case RenderCompletion completion: Console.WriteLine($"{completion.State} / {completion.Status}:" + $" {completion.RenderedCount}/{completion.RequestedCount}"); foreach (var issue in completion.Issues) Console.WriteLine($" {issue}"); break; }}# e.g. inside your Rails / Sinatra actiondef handle_callback(payload) callback = Pagr.parse_callback(payload) # raises PagrDecodeError on a bogus body
case callback when Pagr::RenderProgress puts "[#{callback.document_index}] #{callback.document.document_name}" when Pagr::RenderCompletion puts "#{callback.state} / #{callback.status}: " \ "#{callback.rendered_count}/#{callback.requested_count}" callback.issues.each { |issue| puts " #{issue}" } endend#include "pagr/webhooks.hpp"
void handle_callback(const std::string& body) { const auto callback = pagr::parse_callback(body);
std::visit([](const auto& cb) { using T = std::decay_t<decltype(cb)>; if constexpr (std::is_same_v<T, pagr::RenderProgress>) { std::cout << "[" << cb.document_index << "] " << cb.document.document_name << "\n"; } else { std::cout << cb.rendered_count << "/" << cb.requested_count << "\n"; } }, callback);}parse_callback returns a std::variant of the two callback types — std::visit
over it is exhaustive.
Alternative: poll instead
Section titled “Alternative: poll instead”Webhooks need a public URL Pagr can reach. Polling needs nothing. Both give you the
same state / status split, the same counts, and the same issues — parsed into
the same model — so switching costs nothing.
# Poll every couple of seconds until state is terminalwhile :; do state=$(curl -s "https://pagr-prd-api-public.azurewebsites.net/v1/render/jobs/$JOB_ID" \ -H "Authorization: Bearer pagr_prod_xxxxxxxx" | jq -r .state) [ "$state" = "pending" ] || break sleep 2doneecho "Finished: $state"# wait_for_job wraps the "while not status.done" loop for youstatus = await client.wait_for_job(job.job_id, poll_interval=2.0, timeout=600)
print(status.state.value, status.status.value if status.status else None)print(f"{status.rendered_count}/{status.requested_count}," f" {status.missing_count} missing")if status.failure_reason: print("Failed:", status.failure_reason)const status = await client.waitForJob(job.jobId, { pollIntervalMs: 2000, timeoutMs: 600_000,});
console.log(status.state, status.status);console.log(`${status.renderedCount}/${status.requestedCount}, ${status.missingCount} missing`);if (status.failureReason) console.log('Failed:', status.failureReason);RenderJobStatus status = client.waitForJob( job.getJobId(), Duration.ofSeconds(2), Duration.ofMinutes(10));
System.out.println(status.getState() + " " + status.getStatus());System.out.printf("%d/%d, %d missing%n", status.getRenderedCount(), status.getRequestedCount(), status.getMissingCount());var status = await client.WaitForJobAsync( job.JobId, pollInterval: TimeSpan.FromSeconds(2), timeout: TimeSpan.FromMinutes(10));
Console.WriteLine($"{status.State} {status.Status}");Console.WriteLine($"{status.RenderedCount}/{status.RequestedCount}, {status.MissingCount} missing");status = client.wait_for_job(job.job_id, poll_interval: 2.0, timeout: 600)
puts "#{status.state} #{status.status}"puts "#{status.rendered_count}/#{status.requested_count}, #{status.missing_count} missing"const auto status = client.wait_for_job( job.job_id, std::chrono::seconds(2), std::chrono::minutes(10));
std::cout << status.rendered_count << "/" << status.requested_count << "\n";state vs status
Section titled “state vs status”Two distinct fields, two different questions. This is the most common source of confusion in the async flow:
| Question it answers | Values | |
|---|---|---|
state |
Did the job finish running? | pending, completed, failed |
status |
How did the documents turn out? | ok, partial, failed, insufficient_credit (and null while pending) |
A job can be state: "completed" and status: "partial" at the same time —
it ran to completion, but not every document rendered. Always read both. On the
completion callback state is never pending, because the callback only fires at
a terminal state.
Securing your endpoint
Section titled “Securing your endpoint”Callbacks carry no Authorization header. Anyone who discovers your callback
URL could POST fake payloads at it, so make the URL itself the secret:
- Embed an unguessable token — a query parameter
(
https://your-app.example/pagr/callback?token=<random>) or an unguessable path segment — and reject any request that doesn’t carry it. - Always use HTTPS so the URL and its token aren’t observable in transit.
- Use a per-job token if you want a callback to be usable only for the job it
belongs to; check it against the
jobIdin the payload.
The SDK parsers help here too: they validate the full expected shape before dispatching, so a payload matching neither the progress nor the completion shape raises a decode error rather than being silently mis-parsed into a plausible-looking completion.
Other considerations
Section titled “Other considerations”- Callbacks are retried, so make your handler idempotent. A non-2xx, a timeout
or an error triggers up to 5 attempts with exponential backoff (2 s, 4 s, 8 s,
16 s). You will occasionally see the same callback twice — key your processing
on
jobId+documentIndexand make a repeat a no-op. - Delivery is still best-effort. After 5 failed attempts a callback is abandoned, and under extreme load the delivery queue can shed its oldest pending callbacks. Treat webhooks as an optimisation and polling as the source of truth for anything you must not miss.
- Respond fast — the timeout is about 30 seconds per attempt. Acknowledge and enqueue your own work; don’t render, email, or write reports synchronously in the handler.
- Deliveries are concurrent, not sequential. Up to 16 are in flight at once, so
callbacks — including their
processedvalues — can arrive out of order. Treatprocessedas a snapshot count for a progress bar, not a monotonic counter, and correlate ondocumentIndex. - The polled
issuesarray is capped at 100 per job. The counts stay exact, so trustrenderedCount/missingCountand treatissuesas a diagnostic sample. 503 QueueFullmeans back off. The render queue is at capacity. It’s the one5xxthe SDKs will retry on a read, but the enqueue itself is a write — retry it yourself, with backoff.- Test keys still cap the batch at 10. Rejected at enqueue time with
400 ValidationError. - A job is scoped to your organisation. Polling another tenant’s
jobIdreturns404, not403— the job simply doesn’t exist as far as your key is concerned. - Progress callbacks can carry the PDF. Pass
includeDocument: trueat enqueue time and each progress callback’sdocumentincludes Base64 bytes. Useful to avoid a follow-up download per document; costly on payload size for large batches.
Related articles
Section titled “Related articles”- Render a batch asynchronously — the endpoint and field-level reference.
- Webhooks — the callback payloads and delivery semantics in full.
- Render a batch — the synchronous alternative.
- Render Events — the in-app log of these jobs.
- Handle errors and retries — why the enqueue call isn’t retried for you.
