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 state / status split and the same counts, parsed
into the same model — so the choice is mostly an infrastructure one. The single
substantive difference: the completion webhook carries every issue, while
polling returns at most 100 of them.
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. - For webhooks: your organisation’s webhook signing secret, from Settings → API Keys in the workspace. Callbacks are signed with it, and your receiver needs it to verify them. It’s a separate credential from your API key — see Webhooks → Verifying the signature.
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 and repeat their
X-Pagr-Deliveryid, so dedupe on that header and keep your handler idempotent. -
Verify the signature on the raw body, then parse.
Callbacks carry no
Authorizationheader — theX-Pagr-SignatureHMAC is what proves one came from Pagr. Verify it against the exact bytes you received, before decoding: a re-serialized body will not match the digest. The SDKs’parse_signed_callbackdoes both in one call, and hands you the right typed object — a progress callback carries adocument, 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”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";curl -X POST "https://api.pagr.eu/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" }Step 2: receive the callbacks
Section titled “Step 2: receive the callbacks”Your endpoint gets a plain JSON POST, signed with your organisation’s webhook
secret. Hand the raw body and the X-Pagr-Signature header to the SDK’s
parse_signed_callback: it verifies the HMAC and then returns the right typed
object, so an unverified payload never reaches your business logic. The SDKs are
parser-only by design — none of them bundles a receiver server, so wire this
into whichever HTTP framework you already use.
import osfrom pagr import ( DELIVERY_HEADER, SIGNATURE_HEADER, PagrSignatureError, RenderProgress, parse_signed_callback,)
SECRET = os.environ["PAGR_WEBHOOK_SECRET"] # from Settings → API Keys
# e.g. inside your FastAPI / Flask / aiohttp handlerasync def handle_callback(request): raw_body = await request.body() # the RAW bytes, never a re-serialized dict
try: callback = parse_signed_callback( raw_body, request.headers.get(SIGNATURE_HEADER), SECRET) except PagrSignatureError: return Response(status_code=400) # not from Pagr — don't act on it
if already_seen(request.headers.get(DELIVERY_HEADER)): return Response(status_code=200) # a retry; the id repeats
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}")
return Response(status_code=200)import { DELIVERY_HEADER, PagrSignatureError, RenderCompletion, RenderProgress, SIGNATURE_HEADER, parseSignedCallback,} from 'pagr';
const SECRET = process.env.PAGR_WEBHOOK_SECRET!; // from Settings → API Keys
// Mount with express.raw({ type: 'application/json' }) — NOT express.json(),// which discards the bytes the signature covers.function handleCallback(req: Request, res: Response) { const rawBody = req.body as Buffer; // the RAW bytes const signature = req.header(SIGNATURE_HEADER);
let callback: RenderProgress | RenderCompletion; try { callback = parseSignedCallback(rawBody, signature, SECRET); } catch (err) { if (err instanceof PagrSignatureError) return res.sendStatus(400); throw err; }
if (alreadySeen(req.header(DELIVERY_HEADER))) return res.sendStatus(200);
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}`); } }
return res.sendStatus(200);}import org.example.exception.PagrSignatureException;import org.example.webhook.*;
static final String SECRET = System.getenv("PAGR_WEBHOOK_SECRET");
// e.g. inside your Spring / Javalin handlervoid handleCallback(byte[] rawBody, String signature, String deliveryId) { RenderCallback callback; try { // Verifies against the raw bytes, then parses. callback = Webhooks.parseSignedCallback(rawBody, signature, SECRET); } catch (PagrSignatureException e) { respond(400); // not from Pagr — don't act on it return; }
if (alreadySeen(deliveryId)) { respond(200); return; } // a retry
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)); } respond(200);}Read the body with request.getInputStream() rather than letting a JSON
converter bind it — the signature covers those exact bytes.
Webhooks.SIGNATURE_HEADER and DELIVERY_HEADER give you the header names, and
RenderCallback is a sealed interface permitting only RenderProgress and
RenderCompletion, so a switch over it is exhaustive.
using Pagr.Sdk.Exceptions;using Pagr.Sdk.Webhooks;
static readonly string Secret = Environment.GetEnvironmentVariable("PAGR_WEBHOOK_SECRET")!;
// e.g. inside your ASP.NET Core endpointasync Task HandleCallback(HttpRequest request, HttpResponse response){ // Read the body yourself; model binding would consume the bytes the // signature covers. using var reader = new StreamReader(request.Body); var rawBody = await reader.ReadToEndAsync();
RenderCallback callback; try { callback = WebhookSignature.ParseSignedCallback( rawBody, request.Headers[WebhookSignature.HeaderName], Secret); } catch (PagrSignatureException) { response.StatusCode = 400; // not from Pagr — don't act on it return; }
if (AlreadySeen(request.Headers[WebhookSignature.DeliveryHeaderName])) return; // a retry; the id repeats
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; }}SECRET = ENV.fetch("PAGR_WEBHOOK_SECRET") # from Settings → API Keys
# e.g. inside your Rails / Sinatra actiondef handle_callback(request) raw_body = request.body.read # the RAW bytes, before any JSON parsing
begin callback = Pagr.parse_signed_callback( raw_body, request.get_header("HTTP_X_PAGR_SIGNATURE"), SECRET) rescue Pagr::PagrSignatureError return head :bad_request # not from Pagr — don't act on it end
return head :ok if already_seen?(request.get_header("HTTP_X_PAGR_DELIVERY"))
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}" } end
head :okendPagr::SIGNATURE_HEADER and Pagr::DELIVERY_HEADER hold the header names as
Pagr sends them; Rack rewrites them to the HTTP_… form above.
#include "pagr/webhooks.hpp"
const char* env = std::getenv("PAGR_WEBHOOK_SECRET"); // from Settings → API Keysconst std::string kSecret = env ? env : "";
// `raw_body` is the request body exactly as read off the socket.void handle_callback(std::string_view raw_body, std::optional<std::string_view> signature, std::optional<std::string_view> delivery_id) { try { // Verifies against the raw bytes, then parses — an unverified // payload is never decoded. const auto callback = pagr::webhooks::parse_signed_callback( raw_body, signature, kSecret);
if (already_seen(delivery_id)) { respond(200); return; } // a retry
std::visit([](const auto& cb) { using T = std::decay_t<decltype(cb)>; if constexpr (std::is_same_v<T, pagr::webhooks::RenderProgress>) { std::cout << "[" << cb.document_index << "] " << cb.document.document_name << "\n"; } else { std::cout << cb.rendered_count << "/" << cb.requested_count << "\n"; } }, callback); } catch (const pagr::PagrSignatureException&) { respond(400); // not from Pagr — do not act on it return; } respond(200);}parse_signed_callback returns a std::variant of the two callback types —
std::visit over it is exhaustive. It also accepts a std::span of bytes if
that’s the shape your server hands you.
POST /pagr/callback HTTP/1.1Content-Type: application/jsonX-Pagr-Event: render.progressX-Pagr-Delivery: 9d4f1e02-7c3a-4b18-8f65-2a0c7d91e4b3X-Pagr-Signature: t=1754899200,v1=bcaa0dced1702951e44a0c10c9729c853d59433fbb954a8c299e743abd89b2bf// 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. Without an SDK you verify by hand: recompute
HMAC-SHA256(secret, "<t>.<raw body>") in lowercase hex, compare it in
constant time against each v1, and reject a t outside your tolerance
window. See Verifying the signature.
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 and the same counts, parsed into the same
model — so switching costs almost nothing. The one thing polling can’t match is
the full issues list on a large batch: only the first 100 are stored, so that’s
all a poll can return.
# 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";# Poll every couple of seconds until state is terminalwhile :; do state=$(curl -s "https://api.pagr.eu/v1/render/jobs/$JOB_ID" \ -H "Authorization: Bearer pagr_prod_xxxxxxxx" | jq -r .state) [ "$state" = "pending" ] || break sleep 2doneecho "Finished: $state"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 — the X-Pagr-Signature HMAC is the
credential instead. Anyone who discovers your callback URL can POST at it, so
verification is what tells a genuine callback from a forgery, and it’s the one
thing you must not skip:
- Verify every callback with
parse_signed_callback(or the verify-only helper) against the raw body, using the signing secret from Settings → API Keys. Treat a failure as400and process nothing. - Enforce the timestamp window. The SDKs reject a signature signed more than 300 seconds from your own clock by default. The timestamp is inside the signed material for exactly this reason: without that check, a captured callback stays replayable forever.
- Dedupe on
X-Pagr-Delivery. Retries repeat the id, so this is how you avoid processing the same callback twice.
Then, as defence in depth on top of verification — never instead of it:
- Always use HTTPS, so neither the URL nor the payload is observable in transit.
- Keep the URL hard to guess — a random query token
(
https://your-app.example/pagr/callback?token=<random>) or an unguessable path segment — and reject requests that don’t carry it before you spend a HMAC on them. A URL leaks through logs and proxies, so this filters noise; it doesn’t authenticate. - 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.
Full details of the scheme — the header format, secret rotation, and what a hand-rolled verifier has to check — are on Webhooks.
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 — dedupe on the
X-Pagr-Deliveryheader, which repeats across a callback’s retries, or failing that key onjobId+documentIndexand make a repeat a no-op. - Every attempt is signed afresh. The
tinX-Pagr-Signatureis the time that attempt went out, not the time the job finished, so a retry that lands minutes later still passes a 300-second tolerance check rather than looking like a replay. - 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 — only the first 100 are persisted, so that’s all polling can return. The completion webhook carries them all, because it’s built before anything is written away. Counts are exact on both paths: trustrenderedCount/missingCount, and take the full issue list from the webhook if you need it. 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, delivery semantics and signature scheme in full.
- API Keys — where the webhook signing secret lives, and how to rotate it.
- 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.
