Skip to content

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.

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

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

  3. 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-Delivery id, so dedupe on that header and keep your handler idempotent.

  4. Verify the signature on the raw body, then parse.

    Callbacks carry no Authorization header — the X-Pagr-Signature HMAC 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_callback does both in one call, and hands you the right typed object — a progress callback carries a document, the completion callback doesn’t.

  5. Correlate progress callbacks by documentIndex.

    Documents render in parallel, so callbacks arrive out of input order. Never infer position from arrival order.

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

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

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 os
from 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 handler
async 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)

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 you
status = 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)

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.

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 as 400 and 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 jobId in 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.

  • 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-Delivery header, which repeats across a callback’s retries, or failing that key on jobId + documentIndex and make a repeat a no-op.
  • Every attempt is signed afresh. The t in X-Pagr-Signature is 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 processed values — can arrive out of order. Treat processed as a snapshot count for a progress bar, not a monotonic counter, and correlate on documentIndex.
  • The polled issues array 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: trust renderedCount / missingCount, and take the full issue list from the webhook if you need it.
  • 503 QueueFull means back off. The render queue is at capacity. It’s the one 5xx the 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 jobId returns 404, not 403 — the job simply doesn’t exist as far as your key is concerned.
  • Progress callbacks can carry the PDF. Pass includeDocument: true at enqueue time and each progress callback’s document includes Base64 bytes. Useful to avoid a follow-up download per document; costly on payload size for large batches.